lsp-pi 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,602 @@
1
+ /**
2
+ * Integration tests for LSP - spawns real language servers and detects errors
3
+ *
4
+ * Run with: npm run test:integration
5
+ *
6
+ * Skips tests if language server is not installed.
7
+ */
8
+
9
+ // Suppress stream errors from vscode-jsonrpc when LSP process exits
10
+ process.on('uncaughtException', (err) => {
11
+ if (err.message?.includes('write after end')) return;
12
+ console.error('Uncaught:', err);
13
+ process.exit(1);
14
+ });
15
+
16
+ import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
17
+ import { existsSync, statSync } from "fs";
18
+ import { tmpdir } from "os";
19
+ import { join, delimiter } from "path";
20
+ import { LSPManager } from "../lsp-core.js";
21
+
22
+ // ============================================================================
23
+ // Test utilities
24
+ // ============================================================================
25
+
26
+ const tests: Array<{ name: string; fn: () => Promise<void> }> = [];
27
+ let skipped = 0;
28
+
29
+ function test(name: string, fn: () => Promise<void>) {
30
+ tests.push({ name, fn });
31
+ }
32
+
33
+ function assert(condition: boolean, message: string) {
34
+ if (!condition) throw new Error(message);
35
+ }
36
+
37
+ class SkipTest extends Error {
38
+ constructor(reason: string) {
39
+ super(reason);
40
+ this.name = "SkipTest";
41
+ }
42
+ }
43
+
44
+ function skip(reason: string): never {
45
+ throw new SkipTest(reason);
46
+ }
47
+
48
+ // Search paths matching lsp-core.ts
49
+ const SEARCH_PATHS = [
50
+ ...(process.env.PATH?.split(delimiter) || []),
51
+ "/usr/local/bin",
52
+ "/opt/homebrew/bin",
53
+ `${process.env.HOME || ""}/.pub-cache/bin`,
54
+ `${process.env.HOME || ""}/fvm/default/bin`,
55
+ `${process.env.HOME || ""}/go/bin`,
56
+ `${process.env.HOME || ""}/.cargo/bin`,
57
+ ];
58
+
59
+ function commandExists(cmd: string): boolean {
60
+ for (const dir of SEARCH_PATHS) {
61
+ const full = join(dir, cmd);
62
+ try {
63
+ if (existsSync(full) && statSync(full).isFile()) return true;
64
+ } catch {}
65
+ }
66
+ return false;
67
+ }
68
+
69
+ // ============================================================================
70
+ // TypeScript
71
+ // ============================================================================
72
+
73
+ test("typescript: detects type errors", async () => {
74
+ if (!commandExists("typescript-language-server")) {
75
+ skip("typescript-language-server not installed");
76
+ }
77
+
78
+ const dir = await mkdtemp(join(tmpdir(), "lsp-ts-"));
79
+ const manager = new LSPManager(dir);
80
+
81
+ try {
82
+ await writeFile(join(dir, "package.json"), "{}");
83
+ await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
84
+ compilerOptions: { strict: true, noEmit: true }
85
+ }));
86
+
87
+ // Code with type error
88
+ const file = join(dir, "index.ts");
89
+ await writeFile(file, `const x: string = 123;`);
90
+
91
+ const { diagnostics } = await manager.touchFileAndWait(file, 10000);
92
+
93
+ assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
94
+ assert(
95
+ diagnostics.some(d => d.message.toLowerCase().includes("type") || d.severity === 1),
96
+ `Expected type error, got: ${diagnostics.map(d => d.message).join(", ")}`
97
+ );
98
+ } finally {
99
+ await manager.shutdown();
100
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
101
+ }
102
+ });
103
+
104
+ test("typescript: valid code has no errors", async () => {
105
+ if (!commandExists("typescript-language-server")) {
106
+ skip("typescript-language-server not installed");
107
+ }
108
+
109
+ const dir = await mkdtemp(join(tmpdir(), "lsp-ts-"));
110
+ const manager = new LSPManager(dir);
111
+
112
+ try {
113
+ await writeFile(join(dir, "package.json"), "{}");
114
+ await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
115
+ compilerOptions: { strict: true, noEmit: true }
116
+ }));
117
+
118
+ const file = join(dir, "index.ts");
119
+ await writeFile(file, `const x: string = "hello";`);
120
+
121
+ const { diagnostics } = await manager.touchFileAndWait(file, 10000);
122
+ const errors = diagnostics.filter(d => d.severity === 1);
123
+
124
+ assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
125
+ } finally {
126
+ await manager.shutdown();
127
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
128
+ }
129
+ });
130
+
131
+ // ============================================================================
132
+ // Dart
133
+ // ============================================================================
134
+
135
+ test("dart: detects type errors", async () => {
136
+ if (!commandExists("dart")) {
137
+ skip("dart not installed");
138
+ }
139
+
140
+ const dir = await mkdtemp(join(tmpdir(), "lsp-dart-"));
141
+ const manager = new LSPManager(dir);
142
+
143
+ try {
144
+ await writeFile(join(dir, "pubspec.yaml"), "name: test_app\nenvironment:\n sdk: ^3.0.0");
145
+
146
+ await mkdir(join(dir, "lib"));
147
+ const file = join(dir, "lib/main.dart");
148
+ // Type error: assigning int to String
149
+ await writeFile(file, `
150
+ void main() {
151
+ String x = 123;
152
+ print(x);
153
+ }
154
+ `);
155
+
156
+ const { diagnostics } = await manager.touchFileAndWait(file, 15000);
157
+
158
+ assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
159
+ } finally {
160
+ await manager.shutdown();
161
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
162
+ }
163
+ });
164
+
165
+ test("dart: valid code has no errors", async () => {
166
+ if (!commandExists("dart")) {
167
+ skip("dart not installed");
168
+ }
169
+
170
+ const dir = await mkdtemp(join(tmpdir(), "lsp-dart-"));
171
+ const manager = new LSPManager(dir);
172
+
173
+ try {
174
+ await writeFile(join(dir, "pubspec.yaml"), "name: test_app\nenvironment:\n sdk: ^3.0.0");
175
+
176
+ await mkdir(join(dir, "lib"));
177
+ const file = join(dir, "lib/main.dart");
178
+ await writeFile(file, `
179
+ void main() {
180
+ String x = "hello";
181
+ print(x);
182
+ }
183
+ `);
184
+
185
+ const { diagnostics } = await manager.touchFileAndWait(file, 15000);
186
+ const errors = diagnostics.filter(d => d.severity === 1);
187
+
188
+ assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
189
+ } finally {
190
+ await manager.shutdown();
191
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
192
+ }
193
+ });
194
+
195
+ // ============================================================================
196
+ // Rust
197
+ // ============================================================================
198
+
199
+ test("rust: detects type errors", async () => {
200
+ if (!commandExists("rust-analyzer")) {
201
+ skip("rust-analyzer not installed");
202
+ }
203
+
204
+ const dir = await mkdtemp(join(tmpdir(), "lsp-rust-"));
205
+ const manager = new LSPManager(dir);
206
+
207
+ try {
208
+ await writeFile(join(dir, "Cargo.toml"), `[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"`);
209
+
210
+ await mkdir(join(dir, "src"));
211
+ const file = join(dir, "src/main.rs");
212
+ await writeFile(file, `fn main() {\n let x: i32 = "hello";\n}`);
213
+
214
+ // rust-analyzer needs a LOT of time to initialize (compiles the project)
215
+ const { diagnostics } = await manager.touchFileAndWait(file, 60000);
216
+
217
+ assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
218
+ } finally {
219
+ await manager.shutdown();
220
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
221
+ }
222
+ });
223
+
224
+ test("rust: valid code has no errors", async () => {
225
+ if (!commandExists("rust-analyzer")) {
226
+ skip("rust-analyzer not installed");
227
+ }
228
+
229
+ const dir = await mkdtemp(join(tmpdir(), "lsp-rust-"));
230
+ const manager = new LSPManager(dir);
231
+
232
+ try {
233
+ await writeFile(join(dir, "Cargo.toml"), `[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"`);
234
+
235
+ await mkdir(join(dir, "src"));
236
+ const file = join(dir, "src/main.rs");
237
+ await writeFile(file, `fn main() {\n let x = "hello";\n println!("{}", x);\n}`);
238
+
239
+ const { diagnostics } = await manager.touchFileAndWait(file, 60000);
240
+ const errors = diagnostics.filter(d => d.severity === 1);
241
+
242
+ assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
243
+ } finally {
244
+ await manager.shutdown();
245
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
246
+ }
247
+ });
248
+
249
+ // ============================================================================
250
+ // Go
251
+ // ============================================================================
252
+
253
+ test("go: detects type errors", async () => {
254
+ if (!commandExists("gopls")) {
255
+ skip("gopls not installed");
256
+ }
257
+
258
+ const dir = await mkdtemp(join(tmpdir(), "lsp-go-"));
259
+ const manager = new LSPManager(dir);
260
+
261
+ try {
262
+ await writeFile(join(dir, "go.mod"), "module test\n\ngo 1.21");
263
+
264
+ const file = join(dir, "main.go");
265
+ // Type error: cannot use int as string
266
+ await writeFile(file, `package main
267
+
268
+ func main() {
269
+ var x string = 123
270
+ println(x)
271
+ }
272
+ `);
273
+
274
+ const { diagnostics } = await manager.touchFileAndWait(file, 15000);
275
+
276
+ assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
277
+ } finally {
278
+ await manager.shutdown();
279
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
280
+ }
281
+ });
282
+
283
+ test("go: valid code has no errors", async () => {
284
+ if (!commandExists("gopls")) {
285
+ skip("gopls not installed");
286
+ }
287
+
288
+ const dir = await mkdtemp(join(tmpdir(), "lsp-go-"));
289
+ const manager = new LSPManager(dir);
290
+
291
+ try {
292
+ await writeFile(join(dir, "go.mod"), "module test\n\ngo 1.21");
293
+
294
+ const file = join(dir, "main.go");
295
+ await writeFile(file, `package main
296
+
297
+ func main() {
298
+ var x string = "hello"
299
+ println(x)
300
+ }
301
+ `);
302
+
303
+ const { diagnostics } = await manager.touchFileAndWait(file, 15000);
304
+ const errors = diagnostics.filter(d => d.severity === 1);
305
+
306
+ assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
307
+ } finally {
308
+ await manager.shutdown();
309
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
310
+ }
311
+ });
312
+
313
+ // ============================================================================
314
+ // Kotlin
315
+ // ============================================================================
316
+
317
+ test("kotlin: detects syntax errors", async () => {
318
+ if (!commandExists("kotlin-language-server")) {
319
+ skip("kotlin-language-server not installed");
320
+ }
321
+
322
+ const dir = await mkdtemp(join(tmpdir(), "lsp-kt-"));
323
+ const manager = new LSPManager(dir);
324
+
325
+ try {
326
+ // Minimal Gradle markers so the LSP picks a root
327
+ await writeFile(join(dir, "settings.gradle.kts"), "rootProject.name = \"test\"\n");
328
+ await writeFile(join(dir, "build.gradle.kts"), "// empty\n");
329
+
330
+ await mkdir(join(dir, "src/main/kotlin"), { recursive: true });
331
+ const file = join(dir, "src/main/kotlin/Main.kt");
332
+
333
+ // Syntax error
334
+ await writeFile(file, "fun main() { val x = }\n");
335
+
336
+ const { diagnostics, receivedResponse } = await manager.touchFileAndWait(file, 30000);
337
+
338
+ assert(receivedResponse, "Expected Kotlin LSP to respond");
339
+ assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
340
+ } finally {
341
+ await manager.shutdown();
342
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
343
+ }
344
+ });
345
+
346
+ test("kotlin: valid code has no errors", async () => {
347
+ if (!commandExists("kotlin-language-server")) {
348
+ skip("kotlin-language-server not installed");
349
+ }
350
+
351
+ const dir = await mkdtemp(join(tmpdir(), "lsp-kt-"));
352
+ const manager = new LSPManager(dir);
353
+
354
+ try {
355
+ await writeFile(join(dir, "settings.gradle.kts"), "rootProject.name = \"test\"\n");
356
+ await writeFile(join(dir, "build.gradle.kts"), "// empty\n");
357
+
358
+ await mkdir(join(dir, "src/main/kotlin"), { recursive: true });
359
+ const file = join(dir, "src/main/kotlin/Main.kt");
360
+
361
+ await writeFile(file, "fun main() { val x = 1; println(x) }\n");
362
+
363
+ const { diagnostics, receivedResponse } = await manager.touchFileAndWait(file, 30000);
364
+
365
+ assert(receivedResponse, "Expected Kotlin LSP to respond");
366
+ const errors = diagnostics.filter(d => d.severity === 1);
367
+ assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
368
+ } finally {
369
+ await manager.shutdown();
370
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
371
+ }
372
+ });
373
+
374
+ // ============================================================================
375
+ // Python
376
+ // ============================================================================
377
+
378
+ test("python: detects type errors", async () => {
379
+ if (!commandExists("pyright-langserver")) {
380
+ skip("pyright-langserver not installed");
381
+ }
382
+
383
+ const dir = await mkdtemp(join(tmpdir(), "lsp-py-"));
384
+ const manager = new LSPManager(dir);
385
+
386
+ try {
387
+ await writeFile(join(dir, "pyproject.toml"), `[project]\nname = "test"`);
388
+
389
+ const file = join(dir, "main.py");
390
+ // Type error with type annotation
391
+ await writeFile(file, `
392
+ def greet(name: str) -> str:
393
+ return "Hello, " + name
394
+
395
+ x: str = 123 # Type error
396
+ result = greet(456) # Type error
397
+ `);
398
+
399
+ const { diagnostics } = await manager.touchFileAndWait(file, 10000);
400
+
401
+ assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
402
+ } finally {
403
+ await manager.shutdown();
404
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
405
+ }
406
+ });
407
+
408
+ test("python: valid code has no errors", async () => {
409
+ if (!commandExists("pyright-langserver")) {
410
+ skip("pyright-langserver not installed");
411
+ }
412
+
413
+ const dir = await mkdtemp(join(tmpdir(), "lsp-py-"));
414
+ const manager = new LSPManager(dir);
415
+
416
+ try {
417
+ await writeFile(join(dir, "pyproject.toml"), `[project]\nname = "test"`);
418
+
419
+ const file = join(dir, "main.py");
420
+ await writeFile(file, `
421
+ def greet(name: str) -> str:
422
+ return "Hello, " + name
423
+
424
+ x: str = "world"
425
+ result = greet(x)
426
+ `);
427
+
428
+ const { diagnostics } = await manager.touchFileAndWait(file, 10000);
429
+ const errors = diagnostics.filter(d => d.severity === 1);
430
+
431
+ assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
432
+ } finally {
433
+ await manager.shutdown();
434
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
435
+ }
436
+ });
437
+
438
+ // ============================================================================
439
+ // Rename (TypeScript)
440
+ // ============================================================================
441
+
442
+ test("typescript: rename symbol", async () => {
443
+ if (!commandExists("typescript-language-server")) {
444
+ skip("typescript-language-server not installed");
445
+ }
446
+
447
+ const dir = await mkdtemp(join(tmpdir(), "lsp-ts-rename-"));
448
+ const manager = new LSPManager(dir);
449
+
450
+ try {
451
+ await writeFile(join(dir, "package.json"), "{}");
452
+ await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
453
+ compilerOptions: { strict: true, noEmit: true }
454
+ }));
455
+
456
+ const file = join(dir, "index.ts");
457
+ await writeFile(file, `function greet(name: string) {
458
+ return "Hello, " + name;
459
+ }
460
+ const result = greet("world");
461
+ `);
462
+
463
+ // Touch file first to ensure it's loaded
464
+ await manager.touchFileAndWait(file, 10000);
465
+
466
+ // Rename 'greet' at line 1, col 10
467
+ const edit = await manager.rename(file, 1, 10, "sayHello");
468
+
469
+ if (!edit) throw new Error("Expected rename to return WorkspaceEdit");
470
+ assert(
471
+ edit.changes !== undefined || edit.documentChanges !== undefined,
472
+ "Expected changes or documentChanges in WorkspaceEdit"
473
+ );
474
+
475
+ // Should have edits for both the function definition and the call
476
+ const allEdits: any[] = [];
477
+ if (edit.changes) {
478
+ for (const edits of Object.values(edit.changes)) {
479
+ allEdits.push(...(edits as any[]));
480
+ }
481
+ }
482
+ if (edit.documentChanges) {
483
+ for (const change of edit.documentChanges as any[]) {
484
+ if (change.edits) allEdits.push(...change.edits);
485
+ }
486
+ }
487
+
488
+ assert(allEdits.length >= 2, `Expected at least 2 edits (definition + usage), got ${allEdits.length}`);
489
+ } finally {
490
+ await manager.shutdown();
491
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
492
+ }
493
+ });
494
+
495
+ // ============================================================================
496
+ // Code Actions (TypeScript)
497
+ // ============================================================================
498
+
499
+ test("typescript: get code actions for error", async () => {
500
+ if (!commandExists("typescript-language-server")) {
501
+ skip("typescript-language-server not installed");
502
+ }
503
+
504
+ const dir = await mkdtemp(join(tmpdir(), "lsp-ts-actions-"));
505
+ const manager = new LSPManager(dir);
506
+
507
+ try {
508
+ await writeFile(join(dir, "package.json"), "{}");
509
+ await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
510
+ compilerOptions: { strict: true, noEmit: true }
511
+ }));
512
+
513
+ const file = join(dir, "index.ts");
514
+ // Missing import - should offer "Add import" code action
515
+ await writeFile(file, `const x: Promise<string> = Promise.resolve("hello");
516
+ console.log(x);
517
+ `);
518
+
519
+ // Touch to get diagnostics first
520
+ await manager.touchFileAndWait(file, 10000);
521
+
522
+ // Get code actions at line 1
523
+ const actions = await manager.getCodeActions(file, 1, 1, 1, 50);
524
+
525
+ // May or may not have actions depending on the code, but shouldn't throw
526
+ assert(Array.isArray(actions), "Expected array of code actions");
527
+ } finally {
528
+ await manager.shutdown();
529
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
530
+ }
531
+ });
532
+
533
+ test("typescript: code actions for missing function", async () => {
534
+ if (!commandExists("typescript-language-server")) {
535
+ skip("typescript-language-server not installed");
536
+ }
537
+
538
+ const dir = await mkdtemp(join(tmpdir(), "lsp-ts-actions2-"));
539
+ const manager = new LSPManager(dir);
540
+
541
+ try {
542
+ await writeFile(join(dir, "package.json"), "{}");
543
+ await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
544
+ compilerOptions: { strict: true, noEmit: true }
545
+ }));
546
+
547
+ const file = join(dir, "index.ts");
548
+ // Call undefined function - should offer quick fix
549
+ await writeFile(file, `const result = undefinedFunction();
550
+ `);
551
+
552
+ await manager.touchFileAndWait(file, 10000);
553
+
554
+ // Get code actions where the error is
555
+ const actions = await manager.getCodeActions(file, 1, 16, 1, 33);
556
+
557
+ // TypeScript should offer to create the function
558
+ assert(Array.isArray(actions), "Expected array of code actions");
559
+ // Note: we don't assert on action count since it depends on TS version
560
+ } finally {
561
+ await manager.shutdown();
562
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
563
+ }
564
+ });
565
+
566
+ // ============================================================================
567
+ // Run tests
568
+ // ============================================================================
569
+
570
+ async function runTests(): Promise<void> {
571
+ console.log("Running LSP integration tests...\n");
572
+ console.log("Note: Tests are skipped if language server is not installed.\n");
573
+
574
+ let passed = 0;
575
+ let failed = 0;
576
+
577
+ for (const { name, fn } of tests) {
578
+ try {
579
+ await fn();
580
+ console.log(` ${name}... ✓`);
581
+ passed++;
582
+ } catch (error) {
583
+ if (error instanceof SkipTest) {
584
+ console.log(` ${name}... ⊘ (${error.message})`);
585
+ skipped++;
586
+ } else {
587
+ const msg = error instanceof Error ? error.message : String(error);
588
+ console.log(` ${name}... ✗`);
589
+ console.log(` Error: ${msg}\n`);
590
+ failed++;
591
+ }
592
+ }
593
+ }
594
+
595
+ console.log(`\n${passed} passed, ${failed} failed, ${skipped} skipped`);
596
+
597
+ if (failed > 0) {
598
+ process.exit(1);
599
+ }
600
+ }
601
+
602
+ runTests();