@rank-lang/lsp 0.3.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.
@@ -0,0 +1,1167 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ import { findDefinitionAtPosition, hoverInfoAtPosition } from "@rank-lang/compiler";
7
+ import { collectRankServerAppSurfaceDiagnostics } from "@rank-lang/server-runtime";
8
+ import { describe, expect, test } from "vitest";
9
+ import type { Connection, TextDocumentPositionParams } from "vscode-languageserver/node.js";
10
+ import { TextDocument } from "vscode-languageserver-textdocument";
11
+
12
+ import { publishDiagnostics } from "../src/features/diagnostics.ts";
13
+ import { handleDefinition } from "../src/features/definition.ts";
14
+ import { handleHover } from "../src/features/hover.ts";
15
+ import { ProjectSession } from "../src/session.ts";
16
+ import { toLspDiagnostic, toSourcePosition } from "../src/util/convert.ts";
17
+
18
+ function examplePath(...segments: string[]): string {
19
+ return fileURLToPath(new URL(`../../../examples/${segments.join("/")}`, import.meta.url));
20
+ }
21
+
22
+ function providerFixturePath(...segments: string[]): string {
23
+ return fileURLToPath(new URL(`../../../testdata/modules/${segments.join("/")}`, import.meta.url));
24
+ }
25
+
26
+ const workspaceAwsPackageDirectory = fileURLToPath(new URL("../../plugins/aws", import.meta.url));
27
+
28
+ function serverRuntimeFixturePath(...segments: string[]): string {
29
+ return fileURLToPath(new URL(`../../server-runtime/testdata/${segments.join("/")}`, import.meta.url));
30
+ }
31
+
32
+ async function withTempRankProject(
33
+ files: Record<string, string>,
34
+ run: (entryPath: string) => Promise<void>,
35
+ entryFile = "main.rank",
36
+ ): Promise<void> {
37
+ const directory = await mkdtemp(path.join(os.tmpdir(), "rank-lsp-"));
38
+
39
+ try {
40
+ for (const [relativePath, source] of Object.entries(files)) {
41
+ const absolutePath = path.join(directory, relativePath);
42
+ await mkdir(path.dirname(absolutePath), { recursive: true });
43
+ await writeFile(absolutePath, source, "utf8");
44
+ }
45
+
46
+ await run(path.join(directory, entryFile));
47
+ } finally {
48
+ await rm(directory, { recursive: true, force: true });
49
+ }
50
+ }
51
+
52
+ async function collectLspDiagnostics(entryPath: string) {
53
+ const compiler = await import("../../compiler/dist/index.js");
54
+ const graph = await compiler.loadProjectModuleGraphAsync(entryPath);
55
+
56
+ return [
57
+ ...compiler.resolveModuleImports(graph),
58
+ ...compiler.resolveModuleReferences(graph),
59
+ ...compiler.resolveModuleTypeNames(graph),
60
+ ...compiler.checkModuleTypes(graph),
61
+ ].map(toLspDiagnostic);
62
+ }
63
+
64
+ async function collectRawEditorDiagnostics(entryPath: string) {
65
+ const compiler = await import("../../compiler/dist/index.js");
66
+ const graph = await compiler.loadProjectModuleGraphAsync(entryPath);
67
+ const entryModule = graph.modules[graph.entryModulePath];
68
+ const serveDiagnostics = entryModule
69
+ ? collectRankServerAppSurfaceDiagnostics(graph, entryModule.filePath)
70
+ : [];
71
+
72
+ return [
73
+ ...graph.diagnostics,
74
+ ...compiler.resolveModuleImports(graph),
75
+ ...compiler.resolveModuleReferences(graph),
76
+ ...compiler.resolveModuleTypeNames(graph),
77
+ ...compiler.checkModuleTypes(graph),
78
+ ...serveDiagnostics,
79
+ ].map(toLspDiagnostic);
80
+ }
81
+
82
+ async function collectPublishedDiagnostics(entryPath: string) {
83
+ const session = new ProjectSession();
84
+ const published = new Map<string, ReturnType<typeof toLspDiagnostic>[]>();
85
+ const connection = {
86
+ sendDiagnostics(params: { uri: string; diagnostics: ReturnType<typeof toLspDiagnostic>[] }) {
87
+ published.set(params.uri, params.diagnostics);
88
+ },
89
+ } as Pick<Connection, "sendDiagnostics"> as Connection;
90
+
91
+ await publishDiagnostics(connection, session, entryPath);
92
+
93
+ return published.get(pathToFileURL(path.resolve(entryPath)).href) ?? [];
94
+ }
95
+
96
+ async function collectHover(
97
+ entryPath: string,
98
+ needle: string,
99
+ offset = 0,
100
+ ) {
101
+ const source = await readFile(entryPath, "utf8");
102
+ const index = source.indexOf(needle);
103
+
104
+ if (index < 0) {
105
+ throw new Error(`Missing hover needle: ${needle}`);
106
+ }
107
+
108
+ const document = TextDocument.create(pathToFileURL(entryPath).href, "rank", 1, source);
109
+ const session = new ProjectSession();
110
+
111
+ return handleHover(
112
+ {
113
+ textDocument: { uri: document.uri },
114
+ position: document.positionAt(index + offset),
115
+ },
116
+ session,
117
+ document,
118
+ );
119
+ }
120
+
121
+ describe("editor features", () => {
122
+ test("publish no diagnostics for README-style AWS module qualifiers", async () => {
123
+ await withTempRankProject(
124
+ {
125
+ "rank.toml": [
126
+ "manifestVersion = 1",
127
+ "",
128
+ "[package]",
129
+ 'name = "lsp-aws-provider-import"',
130
+ 'version = "0.1.0"',
131
+ 'source = "src"',
132
+ "",
133
+ "[providers]",
134
+ `aws = { path = "${workspaceAwsPackageDirectory}" }`,
135
+ "",
136
+ ].join("\n"),
137
+ "src/main.rank": [
138
+ "use std::HTTP",
139
+ "use std::Runtime",
140
+ "use aws::{ DynamoDB, S3, SecretsManager }",
141
+ "",
142
+ "HealthRoute = HTTP::Route {",
143
+ " method: `GET`,",
144
+ " path: `/health`",
145
+ "}",
146
+ "",
147
+ "Routes = HealthRoute",
148
+ "",
149
+ "User = Object {",
150
+ " userId: string,",
151
+ " status: `active` | `inactive`,",
152
+ "}",
153
+ "",
154
+ "UsersTable = DynamoDB::Table<User, `userId`>",
155
+ "",
156
+ "dynamoClient: aws::DynamoDB::ClientConfig = DynamoDB::createClient({ region: `us-east-1` })",
157
+ "s3Client: aws::S3::ClientConfig = S3::createClient({ region: `us-east-1` })",
158
+ "secretsClient: aws::SecretsManager::ClientConfig = SecretsManager::createClient({ region: `us-east-1` })",
159
+ "getter = DynamoDB::Item<UsersTable>",
160
+ "",
161
+ "pub main = |req: Runtime::ExecutionContext<Routes>| {",
162
+ " status: 200,",
163
+ " body: {",
164
+ " dynamoRegion: dynamoClient.region,",
165
+ " s3Region: s3Client.region,",
166
+ " secretsRegion: secretsClient.region,",
167
+ " getter: getter,",
168
+ " },",
169
+ "}",
170
+ "",
171
+ ].join("\n"),
172
+ },
173
+ async (entryPath) => {
174
+ const diagnostics = await collectRawEditorDiagnostics(entryPath);
175
+
176
+ expect(diagnostics).toEqual([]);
177
+ },
178
+ "src/main.rank",
179
+ );
180
+ });
181
+
182
+ test("resolve hover and definition from LSP document coordinates", async () => {
183
+ const filePath = examplePath("env-inputs", "main.rank");
184
+ const source = await readFile(filePath, "utf8");
185
+ const document = TextDocument.create(pathToFileURL(filePath).href, "rank", 1, source);
186
+ const session = new ProjectSession();
187
+ const graph = await session.getModuleGraph(filePath);
188
+
189
+ expect(graph).not.toBeNull();
190
+
191
+ if (!graph) {
192
+ return;
193
+ }
194
+
195
+ const mod = session.getLoadedModule(graph, filePath);
196
+
197
+ expect(mod).not.toBeNull();
198
+
199
+ if (!mod) {
200
+ return;
201
+ }
202
+
203
+ const referenceOffset = source.lastIndexOf("config");
204
+ const definitionOffset = source.lastIndexOf("config: AppConfig =");
205
+
206
+ expect(referenceOffset).toBeGreaterThanOrEqual(0);
207
+ expect(definitionOffset).toBeGreaterThanOrEqual(0);
208
+
209
+ const position = toSourcePosition(document.positionAt(referenceOffset), document);
210
+ const hover = hoverInfoAtPosition(graph, mod, position);
211
+ const definition = findDefinitionAtPosition(graph, mod, position);
212
+ const definitionStart = document.positionAt(definitionOffset);
213
+ const definitionEnd = document.positionAt(definitionOffset + "config".length);
214
+
215
+ expect(hover).toContain("config: Object");
216
+ expect(definition).toEqual({
217
+ filePath: path.resolve(filePath),
218
+ span: {
219
+ start: {
220
+ offset: definitionOffset,
221
+ line: definitionStart.line + 1,
222
+ column: definitionStart.character + 1,
223
+ },
224
+ end: {
225
+ offset: definitionOffset + "config".length,
226
+ line: definitionEnd.line + 1,
227
+ column: definitionEnd.character + 1,
228
+ },
229
+ },
230
+ });
231
+ });
232
+
233
+ test("resolve hover and definition for type alias references", async () => {
234
+ const filePath = examplePath("env-inputs", "main.rank");
235
+ const source = await readFile(filePath, "utf8");
236
+ const document = TextDocument.create(pathToFileURL(filePath).href, "rank", 1, source);
237
+ const session = new ProjectSession();
238
+ const referenceOffset = source.indexOf("Env<SysEnv>") + "Env<".length;
239
+ const definitionOffset = source.indexOf("SysEnv = Object {");
240
+
241
+ expect(referenceOffset).toBeGreaterThanOrEqual(0);
242
+ expect(definitionOffset).toBeGreaterThanOrEqual(0);
243
+
244
+ const params: TextDocumentPositionParams = {
245
+ textDocument: { uri: document.uri },
246
+ position: document.positionAt(referenceOffset),
247
+ };
248
+
249
+ const hover = await handleHover(params, session, document);
250
+ const definition = await handleDefinition(params, session, document);
251
+ const definitionStart = document.positionAt(definitionOffset);
252
+ const definitionEnd = document.positionAt(definitionOffset + "SysEnv".length);
253
+
254
+ expect(hover).toEqual({
255
+ contents: {
256
+ kind: "markdown",
257
+ value: "```rank\nSysEnv: Object { APP_ENV: AppEnv, PORT?: Decode<Port>, LOG_LEVEL?: LogLevel, SECRET_KEY: string, ...: string }\n```",
258
+ },
259
+ });
260
+ expect(definition).toEqual({
261
+ uri: pathToFileURL(path.resolve(filePath)).href,
262
+ range: {
263
+ start: definitionStart,
264
+ end: definitionEnd,
265
+ },
266
+ });
267
+ });
268
+
269
+ test("show stdlib hover for imported values", async () => {
270
+ await withTempRankProject(
271
+ {
272
+ "main.rank": "use std::collections::{ map }\nitems = [1, 2]\npub main = || map(items, |item| item + 1)\n",
273
+ },
274
+ async (entryPath) => {
275
+ const hover = await collectHover(entryPath, "map(items", 1);
276
+
277
+ expect(hover).toEqual({
278
+ contents: {
279
+ kind: "markdown",
280
+ value: "```rank\ncollections::map: ([A], (A) -> B) -> [B]\n```\n\nTransforms each item with a callback and returns the collected results.\n\n- Callbacks may declare a trailing `IterativeContext` parameter.",
281
+ },
282
+ });
283
+ },
284
+ );
285
+ });
286
+
287
+ test("resolve hover and definition for values imported through facades", async () => {
288
+ await withTempRankProject(
289
+ {
290
+ "main.rank": "use root::billing::{ quote }\npub main = || quote()\n",
291
+ "billing.rank": "pub use self::pricing::{ quote }\n",
292
+ "billing/pricing.rank": "pub quote = || `ready`\n",
293
+ },
294
+ async (entryPath) => {
295
+ const source = await readFile(entryPath, "utf8");
296
+ const document = TextDocument.create(pathToFileURL(entryPath).href, "rank", 1, source);
297
+ const session = new ProjectSession();
298
+ const referenceOffset = source.indexOf("quote }");
299
+ const pricingPath = path.join(path.dirname(entryPath), "billing", "pricing.rank");
300
+ const pricingSource = await readFile(pricingPath, "utf8");
301
+ const definitionOffset = pricingSource.indexOf("quote = ||");
302
+
303
+ expect(referenceOffset).toBeGreaterThanOrEqual(0);
304
+ expect(definitionOffset).toBeGreaterThanOrEqual(0);
305
+
306
+ const params: TextDocumentPositionParams = {
307
+ textDocument: { uri: document.uri },
308
+ position: document.positionAt(referenceOffset),
309
+ };
310
+ const hover = await handleHover(params, session, document);
311
+ const definition = await handleDefinition(params, session, document);
312
+ const definitionDocument = TextDocument.create(pathToFileURL(pricingPath).href, "rank", 1, pricingSource);
313
+ const definitionStart = definitionDocument.positionAt(definitionOffset);
314
+ const definitionEnd = definitionDocument.positionAt(definitionOffset + "quote".length);
315
+
316
+ expect(hover).toMatchObject({
317
+ contents: {
318
+ kind: "markdown",
319
+ value: expect.stringContaining("quote: () -> string"),
320
+ },
321
+ });
322
+ expect(definition).toEqual({
323
+ uri: pathToFileURL(pricingPath).href,
324
+ range: {
325
+ start: definitionStart,
326
+ end: definitionEnd,
327
+ },
328
+ });
329
+ },
330
+ );
331
+ });
332
+
333
+ test("resolve hover and definition for facade re-export clauses", async () => {
334
+ await withTempRankProject(
335
+ {
336
+ "main.rank": "use root::billing::{ quote }\npub main = || quote()\n",
337
+ "billing.rank": "pub use self::pricing::{ quote }\n",
338
+ "billing/pricing.rank": "pub quote = || `ready`\n",
339
+ },
340
+ async (entryPath) => {
341
+ const facadePath = path.join(path.dirname(entryPath), "billing.rank");
342
+ const facadeSource = await readFile(facadePath, "utf8");
343
+ const document = TextDocument.create(pathToFileURL(facadePath).href, "rank", 1, facadeSource);
344
+ const session = new ProjectSession();
345
+ const referenceOffset = facadeSource.indexOf("quote }");
346
+ const pricingPath = path.join(path.dirname(entryPath), "billing", "pricing.rank");
347
+ const pricingSource = await readFile(pricingPath, "utf8");
348
+ const definitionOffset = pricingSource.indexOf("quote = ||");
349
+
350
+ expect(referenceOffset).toBeGreaterThanOrEqual(0);
351
+ expect(definitionOffset).toBeGreaterThanOrEqual(0);
352
+
353
+ const params: TextDocumentPositionParams = {
354
+ textDocument: { uri: document.uri },
355
+ position: document.positionAt(referenceOffset),
356
+ };
357
+ const hover = await handleHover(params, session, document);
358
+ const definition = await handleDefinition(params, session, document);
359
+ const definitionDocument = TextDocument.create(pathToFileURL(pricingPath).href, "rank", 1, pricingSource);
360
+ const definitionStart = definitionDocument.positionAt(definitionOffset);
361
+ const definitionEnd = definitionDocument.positionAt(definitionOffset + "quote".length);
362
+
363
+ expect(hover).toMatchObject({
364
+ contents: {
365
+ kind: "markdown",
366
+ value: expect.stringContaining("quote: () -> string"),
367
+ },
368
+ });
369
+ expect(definition).toEqual({
370
+ uri: pathToFileURL(pricingPath).href,
371
+ range: {
372
+ start: definitionStart,
373
+ end: definitionEnd,
374
+ },
375
+ });
376
+ },
377
+ );
378
+ });
379
+
380
+ test("show stdlib hover for qualified values", async () => {
381
+ await withTempRankProject(
382
+ {
383
+ "main.rank": "use std::Path\npub main = || Path::join([`k8s`, `deployment.yaml`])\n",
384
+ },
385
+ async (entryPath) => {
386
+ const hover = await collectHover(entryPath, "Path::join", 1);
387
+
388
+ expect(hover).toEqual({
389
+ contents: {
390
+ kind: "markdown",
391
+ value: "```rank\nPath::join: ([string]) -> Path::RelativePath\n```\n\nBuilds one relative path from a list of single path segments.\n\n- Each segment must be non-empty and must not contain `/`.",
392
+ },
393
+ });
394
+ },
395
+ );
396
+ });
397
+
398
+ test("show stdlib hover for imported type exports", async () => {
399
+ await withTempRankProject(
400
+ {
401
+ "main.rank": "use std::Path\ndeployment: Path::RelativePath = Path::join([`README.md`])\npub main = || deployment\n",
402
+ },
403
+ async (entryPath) => {
404
+ const hover = await collectHover(entryPath, "Path::RelativePath", 7);
405
+
406
+ expect(hover).toEqual({
407
+ contents: {
408
+ kind: "markdown",
409
+ value: "```rank\nPath::RelativePath\n```\n\nOpaque pure relative path value used by path-aware stdlib APIs.",
410
+ },
411
+ });
412
+ },
413
+ );
414
+ });
415
+
416
+ test("show stdlib hover for module imports", async () => {
417
+ await withTempRankProject(
418
+ {
419
+ "main.rank": "use std::Path\npub main = || Path::join([`k8s`, `service.yaml`])\n",
420
+ },
421
+ async (entryPath) => {
422
+ const hover = await collectHover(entryPath, "std::Path", 5);
423
+
424
+ expect(hover).toEqual({
425
+ contents: {
426
+ kind: "markdown",
427
+ value: "```rank\nstd::Path\n```\n\nPure relative-path construction and formatting helpers.\n\n- Value exports: from, join, toString\n- Type exports: RelativePath",
428
+ },
429
+ });
430
+ },
431
+ );
432
+ });
433
+
434
+ test("show hover for value binding declarations", async () => {
435
+ await withTempRankProject(
436
+ {
437
+ "main.rank": "services = {\n api: { port: 8080 },\n worker: { port: 9090 },\n admin: { port: 7070 },\n}\n\npub main = || services\n",
438
+ },
439
+ async (entryPath) => {
440
+ const hover = await collectHover(entryPath, "services =", 1);
441
+
442
+ expect(hover).toEqual({
443
+ contents: {
444
+ kind: "markdown",
445
+ value: "```rank\nservices: Object { api: Object { port: number }, worker: Object { port: number }, admin: Object { port: number } }\n```",
446
+ },
447
+ });
448
+ },
449
+ );
450
+ });
451
+
452
+ test("show hover for object field keys in value bindings", async () => {
453
+ await withTempRankProject(
454
+ {
455
+ "main.rank": "services = {\n api: { port: 8080 },\n worker: { port: 9090 },\n admin: { port: 7070 },\n}\n\npub main = || services\n",
456
+ },
457
+ async (entryPath) => {
458
+ const hover = await collectHover(entryPath, "api:", 1);
459
+
460
+ expect(hover).toEqual({
461
+ contents: {
462
+ kind: "markdown",
463
+ value: "```rank\nservices.api: Object { port: number }\n```",
464
+ },
465
+ });
466
+ },
467
+ );
468
+ });
469
+
470
+ test("resolve hover and definition for local function bindings", async () => {
471
+ await withTempRankProject(
472
+ {
473
+ "main.rank": "input = 21\npub main = || {\n doubled = input * 2\n return doubled\n}\n",
474
+ },
475
+ async (entryPath) => {
476
+ const source = await readFile(entryPath, "utf8");
477
+ const document = TextDocument.create(pathToFileURL(entryPath).href, "rank", 1, source);
478
+ const session = new ProjectSession();
479
+ const referenceOffset = source.lastIndexOf("doubled\n");
480
+ const definitionOffset = source.indexOf("doubled = input");
481
+
482
+ expect(referenceOffset).toBeGreaterThanOrEqual(0);
483
+ expect(definitionOffset).toBeGreaterThanOrEqual(0);
484
+
485
+ const params: TextDocumentPositionParams = {
486
+ textDocument: { uri: document.uri },
487
+ position: document.positionAt(referenceOffset),
488
+ };
489
+
490
+ const hover = await handleHover(params, session, document);
491
+ const definition = await handleDefinition(params, session, document);
492
+ const definitionStart = document.positionAt(definitionOffset);
493
+ const definitionEnd = document.positionAt(definitionOffset + "doubled".length);
494
+
495
+ expect(hover).toEqual({
496
+ contents: {
497
+ kind: "markdown",
498
+ value: "```rank\ndoubled: number\n```",
499
+ },
500
+ });
501
+ expect(definition).toEqual({
502
+ uri: pathToFileURL(path.resolve(entryPath)).href,
503
+ range: {
504
+ start: definitionStart,
505
+ end: definitionEnd,
506
+ },
507
+ });
508
+ },
509
+ );
510
+ });
511
+
512
+ test("resolve hover and definition for function parameters", async () => {
513
+ await withTempRankProject(
514
+ {
515
+ "main.rank": "pub main = |input: number| input + 1\n",
516
+ },
517
+ async (entryPath) => {
518
+ const source = await readFile(entryPath, "utf8");
519
+ const document = TextDocument.create(pathToFileURL(entryPath).href, "rank", 1, source);
520
+ const session = new ProjectSession();
521
+ const referenceOffset = source.lastIndexOf("input + 1");
522
+ const definitionOffset = source.indexOf("input: number");
523
+
524
+ expect(referenceOffset).toBeGreaterThanOrEqual(0);
525
+ expect(definitionOffset).toBeGreaterThanOrEqual(0);
526
+
527
+ const params: TextDocumentPositionParams = {
528
+ textDocument: { uri: document.uri },
529
+ position: document.positionAt(referenceOffset),
530
+ };
531
+
532
+ const hover = await handleHover(params, session, document);
533
+ const definition = await handleDefinition(params, session, document);
534
+ const definitionStart = document.positionAt(definitionOffset);
535
+ const definitionEnd = document.positionAt(definitionOffset + "input".length);
536
+
537
+ expect(hover).toEqual({
538
+ contents: {
539
+ kind: "markdown",
540
+ value: "```rank\ninput: number\n```",
541
+ },
542
+ });
543
+ expect(definition).toEqual({
544
+ uri: pathToFileURL(path.resolve(entryPath)).href,
545
+ range: {
546
+ start: definitionStart,
547
+ end: definitionEnd,
548
+ },
549
+ });
550
+ },
551
+ );
552
+ });
553
+
554
+ test("show hover for object field keys inside function-returned object literals", async () => {
555
+ await withTempRankProject(
556
+ {
557
+ "main.rank": "use std::collections::{ reverse }\npub main = || {\n reversed: reverse([1, 2, 3]),\n}\n",
558
+ },
559
+ async (entryPath) => {
560
+ const hover = await collectHover(entryPath, "reversed:", 1);
561
+
562
+ expect(hover).toEqual({
563
+ contents: {
564
+ kind: "markdown",
565
+ value: "```rank\nreversed: [number]\n```",
566
+ },
567
+ });
568
+ },
569
+ );
570
+ });
571
+
572
+ test("show hover for inferred iterative callback context parameters", async () => {
573
+ await withTempRankProject(
574
+ {
575
+ "main.rank": "use std::list::{ range }\nuse std::collections::{ map }\nvalues = range(3)\n |> map(|_, ctx| ctx.index)\npub main = || values\n",
576
+ },
577
+ async (entryPath) => {
578
+ const hover = await collectHover(entryPath, "ctx.index", 1);
579
+
580
+ expect(hover).toEqual({
581
+ contents: {
582
+ kind: "markdown",
583
+ value: "```rank\nctx: Object { index: number, size: number, isFirst: bool, isLast: bool }\n```",
584
+ },
585
+ });
586
+ },
587
+ );
588
+ });
589
+
590
+ test("map unresolved references to their expression span", async () => {
591
+ await withTempRankProject(
592
+ {
593
+ "main.rank": "helper = 1\nvalue = missing\npub main = || value\n",
594
+ },
595
+ async (entryPath) => {
596
+ const diagnostics = await collectLspDiagnostics(entryPath);
597
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "NAM002");
598
+
599
+ expect(diagnostic).toBeDefined();
600
+ expect(diagnostic?.range.start.line).toBe(1);
601
+ },
602
+ );
603
+ });
604
+
605
+ test("map unknown imports to their clause span", async () => {
606
+ await withTempRankProject(
607
+ {
608
+ "main.rank": "/// import repro\nuse std::collections::{ filter, missing }\npub main = || filter\n",
609
+ },
610
+ async (entryPath) => {
611
+ const diagnostics = await collectLspDiagnostics(entryPath);
612
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "NAM001");
613
+
614
+ expect(diagnostic).toBeDefined();
615
+ expect(diagnostic?.range.start.line).toBe(1);
616
+ },
617
+ );
618
+ });
619
+
620
+ test("map unresolved type names to their type annotation span", async () => {
621
+ await withTempRankProject(
622
+ {
623
+ "main.rank": "helper = 1\nvalue: Missing<string> = helper\npub main = || value\n",
624
+ },
625
+ async (entryPath) => {
626
+ const diagnostics = await collectLspDiagnostics(entryPath);
627
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "NAM005");
628
+
629
+ expect(diagnostic).toBeDefined();
630
+ expect(diagnostic?.range.start.line).toBe(1);
631
+ },
632
+ );
633
+ });
634
+
635
+ test("map type mismatches to the annotated binding", async () => {
636
+ await withTempRankProject(
637
+ {
638
+ "main.rank": "helper = 1\nvalue: string = 42\npub main = || value\n",
639
+ },
640
+ async (entryPath) => {
641
+ const diagnostics = await collectLspDiagnostics(entryPath);
642
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP001");
643
+
644
+ expect(diagnostic).toBeDefined();
645
+ expect(diagnostic?.range.start.line).toBe(1);
646
+ },
647
+ );
648
+ });
649
+
650
+ test("map non-callable errors to the call expression", async () => {
651
+ await withTempRankProject(
652
+ {
653
+ "main.rank": "helper = 0\nvalue = 1\nresult = value(2)\npub main = || result\n",
654
+ },
655
+ async (entryPath) => {
656
+ const diagnostics = await collectLspDiagnostics(entryPath);
657
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP003");
658
+
659
+ expect(diagnostic).toBeDefined();
660
+ expect(diagnostic?.range.start.line).toBe(2);
661
+ },
662
+ );
663
+ });
664
+
665
+ test("suppresses matching published diagnostics for declarations with rank-ignore docs", async () => {
666
+ await withTempRankProject(
667
+ {
668
+ "main.rank": [
669
+ "/// rank-ignore NAM002",
670
+ "ignored = missing_value",
671
+ "other = absent_value",
672
+ "pub main = || { ignored, other }",
673
+ "",
674
+ ].join("\n"),
675
+ },
676
+ async (entryPath) => {
677
+ const compilerDiagnostics = await collectLspDiagnostics(entryPath);
678
+ const publishedDiagnostics = await collectPublishedDiagnostics(entryPath);
679
+
680
+ expect(compilerDiagnostics.filter((candidate) => candidate.code === "NAM002")).toHaveLength(2);
681
+ expect(publishedDiagnostics.filter((candidate) => candidate.code === "NAM002")).toHaveLength(1);
682
+ expect(publishedDiagnostics.find((candidate) => candidate.code === "NAM002")?.range.start.line).toBe(2);
683
+ },
684
+ );
685
+ });
686
+
687
+ test("does not suppress non-matching diagnostic codes for rank-ignore docs", async () => {
688
+ await withTempRankProject(
689
+ {
690
+ "main.rank": [
691
+ "/// rank-ignore TYP005",
692
+ "value = missing_value",
693
+ "pub main = || value",
694
+ "",
695
+ ].join("\n"),
696
+ },
697
+ async (entryPath) => {
698
+ const publishedDiagnostics = await collectPublishedDiagnostics(entryPath);
699
+
700
+ expect(publishedDiagnostics.some((candidate) => candidate.code === "NAM002")).toBe(true);
701
+ },
702
+ );
703
+ });
704
+
705
+ test("publishes loader diagnostics for duplicate pub use collisions", async () => {
706
+ await withTempRankProject(
707
+ {
708
+ "main.rank": [
709
+ "pub use super::app::{ main }",
710
+ "",
711
+ "pub main = || main",
712
+ "",
713
+ ].join("\n"),
714
+ "app.rank": "pub main = || 1\n",
715
+ },
716
+ async (entryPath) => {
717
+ const compilerDiagnostics = await collectRawEditorDiagnostics(entryPath);
718
+ const publishedDiagnostics = await collectPublishedDiagnostics(entryPath);
719
+
720
+ expect(compilerDiagnostics.find((candidate) => candidate.code === "MOD005")?.message).toContain(
721
+ "Cannot re-export value main because the current module already exports it.",
722
+ );
723
+ expect(publishedDiagnostics.find((candidate) => candidate.code === "MOD005")?.message).toContain(
724
+ "Cannot re-export value main because the current module already exports it.",
725
+ );
726
+ },
727
+ );
728
+ });
729
+
730
+ test("suppresses entrypoint diagnostics with rank-ignore when the compiler reports a declaration span", async () => {
731
+ const filePath = serverRuntimeFixturePath("invalid-handler-arity", "main.rank");
732
+ const compilerDiagnostics = await collectRawEditorDiagnostics(filePath);
733
+ const publishedDiagnostics = await collectPublishedDiagnostics(filePath);
734
+
735
+ expect(compilerDiagnostics.find((candidate) => candidate.code === "TYP030")?.range.start.line).toBe(1);
736
+ expect(publishedDiagnostics.find((candidate) => candidate.code === "TYP030")).toBeUndefined();
737
+ });
738
+
739
+ test("suppresses missing entrypoint diagnostics with rank-ignore when the compiler anchors them to the first declaration", async () => {
740
+ const filePath = serverRuntimeFixturePath("missing-handler", "main.rank");
741
+ const compilerDiagnostics = await collectRawEditorDiagnostics(filePath);
742
+ const publishedDiagnostics = await collectPublishedDiagnostics(filePath);
743
+
744
+ expect(compilerDiagnostics.find((candidate) => candidate.code === "TYP030")?.range.start.line).toBe(1);
745
+ expect(publishedDiagnostics.find((candidate) => candidate.code === "TYP030")).toBeUndefined();
746
+ });
747
+
748
+ test("suppresses multiple diagnostic codes from a single rank-ignore doc line", async () => {
749
+ await withTempRankProject(
750
+ {
751
+ "main.rank": [
752
+ "/// rank-ignore NAM002 NAM005",
753
+ "value: Missing<string> = missing_value",
754
+ "pub main = || value",
755
+ "",
756
+ ].join("\n"),
757
+ },
758
+ async (entryPath) => {
759
+ const compilerDiagnostics = await collectLspDiagnostics(entryPath);
760
+ const publishedDiagnostics = await collectPublishedDiagnostics(entryPath);
761
+
762
+ expect(compilerDiagnostics.some((candidate) => candidate.code === "NAM002")).toBe(true);
763
+ expect(compilerDiagnostics.some((candidate) => candidate.code === "NAM005")).toBe(true);
764
+ expect(publishedDiagnostics.some((candidate) => candidate.code === "NAM002")).toBe(false);
765
+ expect(publishedDiagnostics.some((candidate) => candidate.code === "NAM005")).toBe(false);
766
+ },
767
+ );
768
+ });
769
+
770
+ test("reports serve-surface config diagnostics to the compiler and suppresses them from published output when ignored", async () => {
771
+ const filePath = serverRuntimeFixturePath("invalid-config-value", "main.rank");
772
+ const compilerDiagnostics = await collectRawEditorDiagnostics(filePath);
773
+ const publishedDiagnostics = await collectPublishedDiagnostics(filePath);
774
+ const diagnostic = compilerDiagnostics.find((candidate) => candidate.code === "SRV004");
775
+
776
+ expect(diagnostic).toBeDefined();
777
+ expect(diagnostic?.message).toContain("Server app pub config must be an object literal or zero-argument function when exported.");
778
+ expect(publishedDiagnostics.find((candidate) => candidate.code === "SRV004")).toBeUndefined();
779
+ });
780
+
781
+ test("reports serve-surface config-arity diagnostics to the compiler and suppresses them from published output when ignored", async () => {
782
+ const filePath = serverRuntimeFixturePath("invalid-config-arity", "main.rank");
783
+ const compilerDiagnostics = await collectRawEditorDiagnostics(filePath);
784
+ const publishedDiagnostics = await collectPublishedDiagnostics(filePath);
785
+ const diagnostic = compilerDiagnostics.find((candidate) => candidate.code === "SRV004");
786
+
787
+ expect(diagnostic).toBeDefined();
788
+ expect(diagnostic?.message).toContain("Server app pub config must be an object literal or zero-argument function when exported.");
789
+ expect(publishedDiagnostics.find((candidate) => candidate.code === "SRV004")).toBeUndefined();
790
+ });
791
+
792
+ test("shows contextual hover for invalid serve config functions", async () => {
793
+ const filePath = serverRuntimeFixturePath("invalid-config-arity", "main.rank");
794
+ const hover = await collectHover(filePath, "config =", 1);
795
+
796
+ expect(hover).toEqual({
797
+ contents: {
798
+ kind: "markdown",
799
+ value: [
800
+ "```rank",
801
+ 'config: (unknown) -> Object { defaultResponseFormat: "json", env: unknown }',
802
+ "```",
803
+ "",
804
+ "Serve config expects `HTTP::AppConfig` or `() -> HTTP::AppConfig`.",
805
+ "",
806
+ "This binding is invalid for serve-time `pub config`: it must be an object literal or zero-argument function.",
807
+ ].join("\n"),
808
+ },
809
+ });
810
+ });
811
+
812
+ test("map call arity errors to the call expression", async () => {
813
+ await withTempRankProject(
814
+ {
815
+ "main.rank": "helper = |value: number| {\n return value\n}\nresult = helper()\npub main = || result\n",
816
+ },
817
+ async (entryPath) => {
818
+ const diagnostics = await collectLspDiagnostics(entryPath);
819
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP004");
820
+
821
+ expect(diagnostic).toBeDefined();
822
+ expect(diagnostic?.range.start.line).toBe(3);
823
+ },
824
+ );
825
+ });
826
+
827
+ test("map field access errors to the field access expression", async () => {
828
+ await withTempRankProject(
829
+ {
830
+ "main.rank": "value = {\n port: 8080,\n}\nresult = value.host\npub main = || result\n",
831
+ },
832
+ async (entryPath) => {
833
+ const diagnostics = await collectLspDiagnostics(entryPath);
834
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP007");
835
+
836
+ expect(diagnostic).toBeDefined();
837
+ expect(diagnostic?.range.start.line).toBe(3);
838
+ },
839
+ );
840
+ });
841
+
842
+ test("map operator errors to the operator expression", async () => {
843
+ await withTempRankProject(
844
+ {
845
+ "main.rank": "left = `one`\nright = 1\nresult = left + right\npub main = || result\n",
846
+ },
847
+ async (entryPath) => {
848
+ const diagnostics = await collectLspDiagnostics(entryPath);
849
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP011");
850
+
851
+ expect(diagnostic).toBeDefined();
852
+ expect(diagnostic?.range.start.line).toBe(2);
853
+ },
854
+ );
855
+ });
856
+
857
+ test("map argument type errors to the offending argument expression", async () => {
858
+ await withTempRankProject(
859
+ {
860
+ "main.rank": "use std::object::{ mapValues }\n\nmapped = mapValues({\n https: 443,\n}, |value: number| {\n return value + 1\n})\n\npub main = || mapped\n",
861
+ },
862
+ async (entryPath) => {
863
+ const diagnostics = await collectLspDiagnostics(entryPath);
864
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP005");
865
+
866
+ expect(diagnostic).toBeDefined();
867
+ expect(diagnostic?.range.start.line).toBe(4);
868
+ },
869
+ );
870
+ });
871
+
872
+ test("map imported function parameter labels to related information", async () => {
873
+ await withTempRankProject(
874
+ {
875
+ "main.rank": "use root::helpers::id\nresult = id(1)\npub main = || result\n",
876
+ "helpers.rank": "pub id = |value: string| -> string {\n return value\n}\n",
877
+ },
878
+ async (entryPath) => {
879
+ const diagnostics = await collectLspDiagnostics(entryPath);
880
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP005");
881
+
882
+ expect(diagnostic).toBeDefined();
883
+ expect(diagnostic?.range.start.line).toBe(1);
884
+ expect(diagnostic?.relatedInformation).toEqual([
885
+ {
886
+ location: {
887
+ uri: pathToFileURL(path.join(path.dirname(entryPath), "helpers.rank")).href,
888
+ range: {
889
+ start: { line: 0, character: 17 },
890
+ end: { line: 0, character: 23 },
891
+ },
892
+ },
893
+ message: "parameter 1 expects string",
894
+ },
895
+ ]);
896
+ },
897
+ );
898
+ });
899
+
900
+ test("map Env diagnostics to the offending call argument", async () => {
901
+ await withTempRankProject(
902
+ {
903
+ "main.rank": "use std::Env\nSchema = Object {\n PORT: string,\n}\nenv = Env<Schema> {\n extra: `value`,\n}\npub main = || env\n",
904
+ },
905
+ async (entryPath) => {
906
+ const diagnostics = await collectLspDiagnostics(entryPath);
907
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP024");
908
+
909
+ expect(diagnostic).toBeDefined();
910
+ expect(diagnostic?.range.start.line).toBe(4);
911
+ },
912
+ );
913
+ });
914
+
915
+ test("map File::Read diagnostics to the offending field expression", async () => {
916
+ await withTempRankProject(
917
+ {
918
+ "main.rank": "use std::File\nConfig = Object {\n value: string,\n}\nconfig = File::Read<Config> {\n path: `config.json`,\n format: `json`,\n}\npub main = || config\n",
919
+ },
920
+ async (entryPath) => {
921
+ const diagnostics = await collectLspDiagnostics(entryPath);
922
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP025");
923
+
924
+ expect(diagnostic).toBeDefined();
925
+ expect(diagnostic?.range.start.line).toBe(5);
926
+ },
927
+ );
928
+ });
929
+
930
+ test("map HTTP::Fetch diagnostics to the offending field expression", async () => {
931
+ await withTempRankProject(
932
+ {
933
+ "main.rank": "use std::HTTP\nBody = Object {\n value: string,\n}\nresponse = HTTP::Fetch<Body> {\n url: `https://api.example.com/data`,\n method: `GET`,\n headers: { accept: 1 },\n cacheKey: `response`,\n}\npub main = || response\n",
934
+ },
935
+ async (entryPath) => {
936
+ const diagnostics = await collectLspDiagnostics(entryPath);
937
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP026");
938
+
939
+ expect(diagnostic).toBeDefined();
940
+ expect(diagnostic?.range.start.line).toBe(7);
941
+ },
942
+ );
943
+ });
944
+
945
+ test("map string interpolation errors to the interpolated expression", async () => {
946
+ await withTempRankProject(
947
+ {
948
+ "main.rank": "name = `svc`\nport = { value: 8080 }\nvalue = `${name}:${port}`\npub main = || value\n",
949
+ },
950
+ async (entryPath) => {
951
+ const diagnostics = await collectLspDiagnostics(entryPath);
952
+ const diagnostic = diagnostics.find((candidate) => candidate.code === "TYP022");
953
+
954
+ expect(diagnostic).toBeDefined();
955
+ expect(diagnostic?.range.start.line).toBe(2);
956
+ },
957
+ );
958
+ });
959
+
960
+ test("provider package modules do not require pub main", async () => {
961
+ const toml = [
962
+ "manifestVersion = 1",
963
+ "[package]",
964
+ 'name = "my-provider"',
965
+ 'version = "0.1.0"',
966
+ 'source = "."',
967
+ "[provider]",
968
+ 'namespace = "my-provider"',
969
+ 'runtime = "node"',
970
+ 'entry = "./dist/provider.js"',
971
+ ].join("\n");
972
+
973
+ await withTempRankProject(
974
+ {
975
+ "rank.toml": toml,
976
+ "types.rank": "pub MyType = Object { value: string }\n",
977
+ },
978
+ async (entryPath) => {
979
+ const diagnostics = await collectLspDiagnostics(entryPath);
980
+ expect(diagnostics.find((d) => d.code === "TYP030")).toBeUndefined();
981
+ },
982
+ "types.rank",
983
+ );
984
+ });
985
+
986
+ test("admits stubbed backend provider capabilities for test fixtures", async () => {
987
+ const filePath = examplePath("tests", "provider-backend-stub", "src", "main.rank");
988
+ const diagnostics = await collectPublishedDiagnostics(filePath);
989
+
990
+ expect(diagnostics.find((candidate) => candidate.code === "TYP027")).toBeUndefined();
991
+ });
992
+
993
+ test("applies manifest provider capability defaults to editor diagnostics", async () => {
994
+ await withTempRankProject(
995
+ {
996
+ "rank.toml": [
997
+ "manifestVersion = 1",
998
+ "",
999
+ "[package]",
1000
+ 'name = "lsp-provider-defaults"',
1001
+ 'version = "0.1.0"',
1002
+ 'source = "src"',
1003
+ "",
1004
+ "[providers]",
1005
+ 'api = { path = "./providers/api" }',
1006
+ "",
1007
+ "[security]",
1008
+ 'allow-provider-capabilities = ["network"]',
1009
+ "",
1010
+ ].join("\n"),
1011
+ "src/main.rank": [
1012
+ "use api::users::{ lookup }",
1013
+ "",
1014
+ "pub main = || lookup({ id: `123`, cacheKey: `lookup-123` })",
1015
+ "",
1016
+ ].join("\n"),
1017
+ "providers/api/rank.toml": [
1018
+ "manifestVersion = 1",
1019
+ "",
1020
+ "[package]",
1021
+ 'name = "api-provider"',
1022
+ 'version = "0.1.0"',
1023
+ 'source = "."',
1024
+ "",
1025
+ "[provider]",
1026
+ 'namespace = "api"',
1027
+ 'runtime = "node"',
1028
+ 'entry = "./dist/provider.js"',
1029
+ "",
1030
+ "[[provider.exports]]",
1031
+ 'name = "users::lookup"',
1032
+ 'kind = "backend"',
1033
+ 'inputSchema = "Object { id: string, cacheKey: string }"',
1034
+ 'outputSchema = "Object { name: string }"',
1035
+ 'capabilities = ["network"]',
1036
+ 'reproducibility = ["cacheKey"]',
1037
+ "",
1038
+ ].join("\n"),
1039
+ "providers/api/dist/provider.js": "export const provider = true;\n",
1040
+ },
1041
+ async (entryPath) => {
1042
+ const diagnostics = await collectPublishedDiagnostics(entryPath);
1043
+
1044
+ expect(diagnostics.find((candidate) => candidate.code === "TYP027")).toBeUndefined();
1045
+ },
1046
+ "src/main.rank",
1047
+ );
1048
+ });
1049
+
1050
+ test("refreshes published diagnostics after rank.toml security defaults change", async () => {
1051
+ const providerDirectory = providerFixturePath("provider-network-backend", "providers", "api");
1052
+
1053
+ await withTempRankProject(
1054
+ {
1055
+ "rank.toml": [
1056
+ "manifestVersion = 1",
1057
+ "",
1058
+ "[package]",
1059
+ 'name = "lsp-provider-refresh"',
1060
+ 'version = "0.1.0"',
1061
+ 'source = "src"',
1062
+ "",
1063
+ "[providers]",
1064
+ `api = { path = ${JSON.stringify(providerDirectory)} }`,
1065
+ "",
1066
+ ].join("\n"),
1067
+ "src/main.rank": [
1068
+ "use api::users::{ lookup }",
1069
+ "",
1070
+ "pub main = || lookup({ id: `123`, cacheKey: `lookup-123` })",
1071
+ "",
1072
+ ].join("\n"),
1073
+ },
1074
+ async (entryPath) => {
1075
+ const session = new ProjectSession();
1076
+ const published = new Map<string, ReturnType<typeof toLspDiagnostic>[]>();
1077
+ const connection = {
1078
+ sendDiagnostics(params: { uri: string; diagnostics: ReturnType<typeof toLspDiagnostic>[] }) {
1079
+ published.set(params.uri, params.diagnostics);
1080
+ },
1081
+ } as Pick<Connection, "sendDiagnostics"> as Connection;
1082
+ const entryUri = pathToFileURL(path.resolve(entryPath)).href;
1083
+ const manifestPath = path.join(path.dirname(path.dirname(entryPath)), "rank.toml");
1084
+
1085
+ await publishDiagnostics(connection, session, entryPath);
1086
+
1087
+ expect(published.get(entryUri)?.find((candidate) => candidate.code === "TYP027")).toBeDefined();
1088
+
1089
+ await writeFile(manifestPath, [
1090
+ "manifestVersion = 1",
1091
+ "",
1092
+ "[package]",
1093
+ 'name = "lsp-provider-refresh"',
1094
+ 'version = "0.1.0"',
1095
+ 'source = "src"',
1096
+ "",
1097
+ "[providers]",
1098
+ `api = { path = ${JSON.stringify(providerDirectory)} }`,
1099
+ "",
1100
+ "[security]",
1101
+ 'allow-provider-capabilities = ["network"]',
1102
+ "",
1103
+ ].join("\n"), "utf8");
1104
+
1105
+ session.invalidate(manifestPath);
1106
+ await publishDiagnostics(connection, session, entryPath);
1107
+
1108
+ expect(published.get(entryUri)?.find((candidate) => candidate.code === "TYP027")).toBeUndefined();
1109
+ },
1110
+ "src/main.rank",
1111
+ );
1112
+ });
1113
+
1114
+ test("applies manifest HTTP host allowlist defaults to editor diagnostics", async () => {
1115
+ await withTempRankProject(
1116
+ {
1117
+ "rank.toml": [
1118
+ "manifestVersion = 1",
1119
+ "",
1120
+ "[package]",
1121
+ 'name = "lsp-http-allowlist"',
1122
+ 'version = "0.1.0"',
1123
+ 'source = "src"',
1124
+ "",
1125
+ "[security]",
1126
+ 'allow-http-hosts = ["api.example.com"]',
1127
+ "",
1128
+ ].join("\n"),
1129
+ "src/main.rank": [
1130
+ "use std::HTTP",
1131
+ "",
1132
+ "Body = Object {",
1133
+ " value: string,",
1134
+ "}",
1135
+ "",
1136
+ "response = HTTP::Fetch<Body> {",
1137
+ " url: `https://blocked.example.com/data`,",
1138
+ " method: `GET`,",
1139
+ " cacheKey: `response`,",
1140
+ "}",
1141
+ "",
1142
+ "pub main = || response",
1143
+ "",
1144
+ ].join("\n"),
1145
+ },
1146
+ async (entryPath) => {
1147
+ const diagnostics = await collectPublishedDiagnostics(entryPath);
1148
+
1149
+ expect(diagnostics.find((candidate) => candidate.code === "TYP031")).toBeDefined();
1150
+ },
1151
+ "src/main.rank",
1152
+ );
1153
+ });
1154
+
1155
+ test("shows provider schema hover for imported backend exports", async () => {
1156
+ const filePath = examplePath("tests", "provider-backend-stub", "src", "main.rank");
1157
+ const hover = await collectHover(filePath, "lookup({", 1);
1158
+
1159
+ expect(hover).toEqual({
1160
+ contents: {
1161
+ kind: "markdown",
1162
+ value: "```rank\nlookup: (Object { id: string, cacheKey: string }) -> Object { name: string }\n```",
1163
+ },
1164
+ });
1165
+ });
1166
+
1167
+ });