@platformos/platformos-language-server-common 0.0.12 → 0.0.16

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 (75) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/PropertyShapeInference.d.ts +5 -0
  3. package/dist/PropertyShapeInference.js +28 -0
  4. package/dist/PropertyShapeInference.js.map +1 -1
  5. package/dist/TypeSystem.js +106 -55
  6. package/dist/TypeSystem.js.map +1 -1
  7. package/dist/VariableShapeExtractor.js +2 -4
  8. package/dist/VariableShapeExtractor.js.map +1 -1
  9. package/dist/codeActions/CodeActionsProvider.d.ts +1 -1
  10. package/dist/completions/CompletionsProvider.d.ts +1 -0
  11. package/dist/completions/CompletionsProvider.js +6 -0
  12. package/dist/completions/CompletionsProvider.js.map +1 -1
  13. package/dist/completions/providers/GraphQLFieldCompletionProvider.d.ts +12 -0
  14. package/dist/completions/providers/GraphQLFieldCompletionProvider.js +75 -0
  15. package/dist/completions/providers/GraphQLFieldCompletionProvider.js.map +1 -0
  16. package/dist/definitions/DefinitionProvider.d.ts +19 -1
  17. package/dist/definitions/DefinitionProvider.js +33 -1
  18. package/dist/definitions/DefinitionProvider.js.map +1 -1
  19. package/dist/definitions/providers/PageRouteDefinitionProvider.d.ts +51 -0
  20. package/dist/definitions/providers/PageRouteDefinitionProvider.js +204 -0
  21. package/dist/definitions/providers/PageRouteDefinitionProvider.js.map +1 -0
  22. package/dist/definitions/providers/RenderPartialDefinitionProvider.d.ts +14 -0
  23. package/dist/definitions/providers/RenderPartialDefinitionProvider.js +50 -0
  24. package/dist/definitions/providers/RenderPartialDefinitionProvider.js.map +1 -0
  25. package/dist/diagnostics/runChecks.d.ts +3 -1
  26. package/dist/diagnostics/runChecks.js +2 -1
  27. package/dist/diagnostics/runChecks.js.map +1 -1
  28. package/dist/documentLinks/DocumentLinksProvider.d.ts +3 -1
  29. package/dist/documentLinks/DocumentLinksProvider.js +18 -20
  30. package/dist/documentLinks/DocumentLinksProvider.js.map +1 -1
  31. package/dist/hover/HoverProvider.d.ts +1 -0
  32. package/dist/hover/HoverProvider.js +6 -0
  33. package/dist/hover/HoverProvider.js.map +1 -1
  34. package/dist/hover/providers/GraphQLFieldHoverProvider.d.ts +12 -0
  35. package/dist/hover/providers/GraphQLFieldHoverProvider.js +85 -0
  36. package/dist/hover/providers/GraphQLFieldHoverProvider.js.map +1 -0
  37. package/dist/server/AppGraphManager.js +3 -4
  38. package/dist/server/AppGraphManager.js.map +1 -1
  39. package/dist/server/CachedFileSystem.js +7 -2
  40. package/dist/server/CachedFileSystem.js.map +1 -1
  41. package/dist/server/startServer.js +71 -5
  42. package/dist/server/startServer.js.map +1 -1
  43. package/dist/test/MockConnection.d.ts +1 -1
  44. package/dist/test/MockConnection.js +11 -4
  45. package/dist/test/MockConnection.js.map +1 -1
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/dist/utils/searchPaths.d.ts +21 -0
  48. package/dist/utils/searchPaths.js +34 -0
  49. package/dist/utils/searchPaths.js.map +1 -0
  50. package/package.json +6 -4
  51. package/src/PropertyShapeInference.spec.ts +44 -0
  52. package/src/PropertyShapeInference.ts +27 -0
  53. package/src/TypeSystem.spec.ts +59 -0
  54. package/src/TypeSystem.ts +119 -58
  55. package/src/VariableShapeExtractor.ts +2 -4
  56. package/src/completions/CompletionsProvider.ts +11 -0
  57. package/src/completions/providers/GraphQLFieldCompletionProvider.ts +78 -0
  58. package/src/definitions/DefinitionProvider.ts +52 -0
  59. package/src/definitions/providers/PageRouteDefinitionProvider.spec.ts +776 -0
  60. package/src/definitions/providers/PageRouteDefinitionProvider.ts +250 -0
  61. package/src/definitions/providers/RenderPartialDefinitionProvider.spec.ts +226 -0
  62. package/src/definitions/providers/RenderPartialDefinitionProvider.ts +70 -0
  63. package/src/diagnostics/runChecks.ts +4 -0
  64. package/src/documentLinks/DocumentLinksProvider.spec.ts +131 -10
  65. package/src/documentLinks/DocumentLinksProvider.ts +27 -35
  66. package/src/hover/HoverProvider.ts +11 -0
  67. package/src/hover/providers/GraphQLFieldHoverProvider.spec.ts +124 -0
  68. package/src/hover/providers/GraphQLFieldHoverProvider.ts +90 -0
  69. package/src/server/AppGraphManager.ts +3 -5
  70. package/src/server/CachedFileSystem.ts +11 -2
  71. package/src/server/startServer.spec.ts +62 -0
  72. package/src/server/startServer.ts +85 -5
  73. package/src/test/MockConnection.ts +16 -4
  74. package/src/utils/searchPaths.spec.ts +101 -0
  75. package/src/utils/searchPaths.ts +32 -0
@@ -0,0 +1,776 @@
1
+ import { assert, beforeEach, describe, expect, it } from 'vitest';
2
+ import { DefinitionParams, LocationLink } from 'vscode-languageserver-protocol';
3
+ import { AbstractFileSystem, FileType } from '@platformos/platformos-common';
4
+ import { DocumentManager } from '../../documents';
5
+ import { DefinitionProvider } from '../DefinitionProvider';
6
+
7
+ function createMockFs(files: Record<string, string>): AbstractFileSystem {
8
+ const fileUris = new Map<string, string>();
9
+ const dirEntries = new Map<string, [string, FileType][]>();
10
+
11
+ for (const [path, content] of Object.entries(files)) {
12
+ const uri = `file:///project/${path}`;
13
+ fileUris.set(uri, content);
14
+
15
+ // Build directory entries
16
+ const parts = path.split('/');
17
+ for (let i = 0; i < parts.length; i++) {
18
+ const dirPath = parts.slice(0, i + 1).join('/');
19
+ const parentPath = parts.slice(0, i).join('/');
20
+ const parentUri = parentPath ? `file:///project/${parentPath}` : 'file:///project';
21
+ const childUri = `file:///project/${dirPath}`;
22
+ const isFile = i === parts.length - 1;
23
+
24
+ if (!dirEntries.has(parentUri)) dirEntries.set(parentUri, []);
25
+ const entries = dirEntries.get(parentUri)!;
26
+ if (!entries.some(([u]) => u === childUri)) {
27
+ entries.push([childUri, isFile ? FileType.File : FileType.Directory]);
28
+ }
29
+ }
30
+ }
31
+
32
+ return {
33
+ async stat(uri: string) {
34
+ if (fileUris.has(uri)) return { type: FileType.File, size: fileUris.get(uri)!.length };
35
+ if (dirEntries.has(uri)) return { type: FileType.Directory, size: 0 };
36
+ throw new Error(`ENOENT: ${uri}`);
37
+ },
38
+ async readFile(uri: string) {
39
+ if (fileUris.has(uri)) return fileUris.get(uri)!;
40
+ throw new Error(`ENOENT: ${uri}`);
41
+ },
42
+ async readDirectory(uri: string) {
43
+ if (dirEntries.has(uri)) return dirEntries.get(uri)!;
44
+ throw new Error(`ENOENT: ${uri}`);
45
+ },
46
+ };
47
+ }
48
+
49
+ describe('Module: PageRouteDefinitionProvider', () => {
50
+ let provider: DefinitionProvider;
51
+ let documentManager: DocumentManager;
52
+
53
+ function setup(pageFiles: Record<string, string>) {
54
+ documentManager = new DocumentManager();
55
+
56
+ // Add .pos sentinel file so findAppRootURI works
57
+ const files: Record<string, string> = { '.pos': '' };
58
+ for (const [path, content] of Object.entries(pageFiles)) {
59
+ files[path] = content;
60
+ }
61
+
62
+ const fs = createMockFs(files);
63
+ const findAppRootURI = async (_uri: string) => 'file:///project';
64
+ const mockGetDefaultLocaleSourceCode = async () => null;
65
+
66
+ provider = new DefinitionProvider(
67
+ documentManager,
68
+ mockGetDefaultLocaleSourceCode,
69
+ fs,
70
+ findAppRootURI,
71
+ );
72
+ }
73
+
74
+ it('navigates to a static page from href', async () => {
75
+ setup({
76
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
77
+ });
78
+
79
+ documentManager.open(
80
+ 'file:///project/app/views/pages/home.html.liquid',
81
+ '<a href="/about">About</a>',
82
+ 1,
83
+ );
84
+
85
+ const params: DefinitionParams = {
86
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
87
+ position: { line: 0, character: 11 }, // Inside "/about"
88
+ };
89
+
90
+ const result = await provider.definitions(params);
91
+ assert(result);
92
+ expect(result).toHaveLength(1);
93
+ assert(LocationLink.is(result[0]));
94
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
95
+ });
96
+
97
+ it('navigates to a dynamic route page', async () => {
98
+ setup({
99
+ 'app/views/pages/user.html.liquid': '---\nslug: users/:id\n---\n<h1>User</h1>',
100
+ });
101
+
102
+ documentManager.open(
103
+ 'file:///project/app/views/pages/home.html.liquid',
104
+ '<a href="/users/42">User</a>',
105
+ 1,
106
+ );
107
+
108
+ const params: DefinitionParams = {
109
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
110
+ position: { line: 0, character: 12 }, // Inside "/users/42"
111
+ };
112
+
113
+ const result = await provider.definitions(params);
114
+ assert(result);
115
+ expect(result).toHaveLength(1);
116
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/user.html.liquid');
117
+ });
118
+
119
+ it('navigates to POST page from form action', async () => {
120
+ setup({
121
+ 'app/views/pages/contact.html.liquid': '---\nmethod: post\n---\n<h1>Contact</h1>',
122
+ });
123
+
124
+ documentManager.open(
125
+ 'file:///project/app/views/pages/home.html.liquid',
126
+ '<form action="/contact" method="post"><button>Send</button></form>',
127
+ 1,
128
+ );
129
+
130
+ const params: DefinitionParams = {
131
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
132
+ position: { line: 0, character: 16 }, // Inside "/contact"
133
+ };
134
+
135
+ const result = await provider.definitions(params);
136
+ assert(result);
137
+ expect(result).toHaveLength(1);
138
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/contact.html.liquid');
139
+ });
140
+
141
+ it('navigates to DELETE page from form with _method inside a div wrapper', async () => {
142
+ setup({
143
+ 'app/views/pages/user-delete.html.liquid': '---\nslug: users/:id\nmethod: delete\n---\n',
144
+ });
145
+
146
+ documentManager.open(
147
+ 'file:///project/app/views/pages/home.html.liquid',
148
+ '<form action="/users/1" method="post"><div><input type="hidden" name="_method" value="delete"></div><button>Delete</button></form>',
149
+ 1,
150
+ );
151
+
152
+ const params: DefinitionParams = {
153
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
154
+ position: { line: 0, character: 16 }, // Inside "/users/1"
155
+ };
156
+
157
+ const result = await provider.definitions(params);
158
+ assert(result);
159
+ expect(result).toHaveLength(1);
160
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/user-delete.html.liquid');
161
+ });
162
+
163
+ it('returns null for external URLs', async () => {
164
+ setup({});
165
+
166
+ documentManager.open(
167
+ 'file:///project/app/views/pages/home.html.liquid',
168
+ '<a href="https://example.com">External</a>',
169
+ 1,
170
+ );
171
+
172
+ const params: DefinitionParams = {
173
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
174
+ position: { line: 0, character: 12 }, // Inside "https://example.com"
175
+ };
176
+
177
+ const result = await provider.definitions(params);
178
+ assert(result === null);
179
+ });
180
+
181
+ it('returns null when no matching page exists', async () => {
182
+ setup({
183
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
184
+ });
185
+
186
+ documentManager.open(
187
+ 'file:///project/app/views/pages/home.html.liquid',
188
+ '<a href="/nonexistent">Link</a>',
189
+ 1,
190
+ );
191
+
192
+ const params: DefinitionParams = {
193
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
194
+ position: { line: 0, character: 12 }, // Inside "/nonexistent"
195
+ };
196
+
197
+ const result = await provider.definitions(params);
198
+ assert(result === null);
199
+ });
200
+
201
+ it('navigates with Liquid interpolation in href', async () => {
202
+ setup({
203
+ 'app/views/pages/user.html.liquid': '---\nslug: users/:id\n---\n<h1>User</h1>',
204
+ });
205
+
206
+ documentManager.open(
207
+ 'file:///project/app/views/pages/home.html.liquid',
208
+ '<a href="/users/{{ user.id }}">User</a>',
209
+ 1,
210
+ );
211
+
212
+ const params: DefinitionParams = {
213
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
214
+ position: { line: 0, character: 11 }, // Inside the href value, on "/users/"
215
+ };
216
+
217
+ const result = await provider.definitions(params);
218
+ assert(result);
219
+ expect(result).toHaveLength(1);
220
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/user.html.liquid');
221
+ });
222
+
223
+ it('navigates to page when <a> is deeply nested inside other elements', async () => {
224
+ setup({
225
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
226
+ });
227
+
228
+ documentManager.open(
229
+ 'file:///project/app/views/pages/home.html.liquid',
230
+ '<div><table><tbody><tr><td><a href="/about">About</a></td></tr></tbody></table></div>',
231
+ 1,
232
+ );
233
+
234
+ const params: DefinitionParams = {
235
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
236
+ position: { line: 0, character: 37 }, // Inside "/about"
237
+ };
238
+
239
+ const result = await provider.definitions(params);
240
+ assert(result);
241
+ expect(result).toHaveLength(1);
242
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
243
+ });
244
+
245
+ it('navigates when cursor is on the tag name of <a>', async () => {
246
+ setup({
247
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
248
+ });
249
+
250
+ documentManager.open(
251
+ 'file:///project/app/views/pages/home.html.liquid',
252
+ '<a href="/about">About</a>',
253
+ 1,
254
+ );
255
+
256
+ const params: DefinitionParams = {
257
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
258
+ position: { line: 0, character: 1 }, // On the "a" tag name
259
+ };
260
+
261
+ const result = await provider.definitions(params);
262
+ assert(result);
263
+ expect(result).toHaveLength(1);
264
+ assert(LocationLink.is(result[0]));
265
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
266
+ });
267
+
268
+ it('navigates when cursor is on the href attribute name', async () => {
269
+ setup({
270
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
271
+ });
272
+
273
+ documentManager.open(
274
+ 'file:///project/app/views/pages/home.html.liquid',
275
+ '<a href="/about">About</a>',
276
+ 1,
277
+ );
278
+
279
+ const params: DefinitionParams = {
280
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
281
+ position: { line: 0, character: 4 }, // On "href"
282
+ };
283
+
284
+ const result = await provider.definitions(params);
285
+ assert(result);
286
+ expect(result).toHaveLength(1);
287
+ assert(LocationLink.is(result[0]));
288
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
289
+ });
290
+
291
+ it('returns null when cursor is on a non-URL attribute of <a>', async () => {
292
+ setup({
293
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
294
+ });
295
+
296
+ documentManager.open(
297
+ 'file:///project/app/views/pages/home.html.liquid',
298
+ '<a class="link" href="/about">About</a>',
299
+ 1,
300
+ );
301
+
302
+ const params: DefinitionParams = {
303
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
304
+ position: { line: 0, character: 5 }, // On "class" attr name
305
+ };
306
+
307
+ const result = await provider.definitions(params);
308
+ assert(result === null);
309
+ });
310
+
311
+ it('navigates when cursor is on the tag name of <form>', async () => {
312
+ setup({
313
+ 'app/views/pages/contact.html.liquid': '---\nmethod: post\n---\n<h1>Contact</h1>',
314
+ });
315
+
316
+ documentManager.open(
317
+ 'file:///project/app/views/pages/home.html.liquid',
318
+ '<form action="/contact" method="post"><button>Send</button></form>',
319
+ 1,
320
+ );
321
+
322
+ const params: DefinitionParams = {
323
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
324
+ position: { line: 0, character: 2 }, // On "form" tag name
325
+ };
326
+
327
+ const result = await provider.definitions(params);
328
+ assert(result);
329
+ expect(result).toHaveLength(1);
330
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/contact.html.liquid');
331
+ });
332
+
333
+ it('navigates when cursor is on action attribute name of <form>', async () => {
334
+ setup({
335
+ 'app/views/pages/contact.html.liquid': '---\nmethod: post\n---\n<h1>Contact</h1>',
336
+ });
337
+
338
+ documentManager.open(
339
+ 'file:///project/app/views/pages/home.html.liquid',
340
+ '<form action="/contact" method="post"><button>Send</button></form>',
341
+ 1,
342
+ );
343
+
344
+ const params: DefinitionParams = {
345
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
346
+ position: { line: 0, character: 8 }, // On "action" attr name
347
+ };
348
+
349
+ const result = await provider.definitions(params);
350
+ assert(result);
351
+ expect(result).toHaveLength(1);
352
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/contact.html.liquid');
353
+ });
354
+
355
+ it('returns null when cursor is on tag name of non-a/form element', async () => {
356
+ setup({
357
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
358
+ });
359
+
360
+ documentManager.open(
361
+ 'file:///project/app/views/pages/home.html.liquid',
362
+ '<div><a href="/about">About</a></div>',
363
+ 1,
364
+ );
365
+
366
+ const params: DefinitionParams = {
367
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
368
+ position: { line: 0, character: 2 }, // On "div" tag name
369
+ };
370
+
371
+ const result = await provider.definitions(params);
372
+ assert(result === null);
373
+ });
374
+
375
+ it('navigates from absolute self-referencing URL', async () => {
376
+ setup({
377
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
378
+ });
379
+
380
+ documentManager.open(
381
+ 'file:///project/app/views/pages/home.html.liquid',
382
+ '<a href="https://{{ context.location.host }}/about">About</a>',
383
+ 1,
384
+ );
385
+
386
+ const params: DefinitionParams = {
387
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
388
+ position: { line: 0, character: 12 }, // Inside the href value
389
+ };
390
+
391
+ const result = await provider.definitions(params);
392
+ assert(result);
393
+ expect(result).toHaveLength(1);
394
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
395
+ });
396
+
397
+ describe('assign-tracking', () => {
398
+ it('navigates when href uses a variable assigned with a static URL', async () => {
399
+ setup({
400
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
401
+ });
402
+
403
+ documentManager.open(
404
+ 'file:///project/app/views/pages/home.html.liquid',
405
+ '{% assign url = "/about" %}\n<a href="{{ url }}">About</a>',
406
+ 1,
407
+ );
408
+
409
+ const params: DefinitionParams = {
410
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
411
+ position: { line: 1, character: 12 }, // Inside {{ url }}
412
+ };
413
+
414
+ const result = await provider.definitions(params);
415
+ assert(result);
416
+ expect(result).toHaveLength(1);
417
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
418
+ });
419
+
420
+ it('navigates when href uses a variable built with append filters', async () => {
421
+ setup({
422
+ 'app/views/pages/group-edit.html.liquid': '---\nslug: groups/:id/edit\n---\n',
423
+ });
424
+
425
+ documentManager.open(
426
+ 'file:///project/app/views/pages/home.html.liquid',
427
+ '{% assign url = "/groups/" | append: group.id | append: "/edit" %}\n<a href="{{ url }}">Edit</a>',
428
+ 1,
429
+ );
430
+
431
+ const params: DefinitionParams = {
432
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
433
+ position: { line: 1, character: 12 }, // Inside {{ url }}
434
+ };
435
+
436
+ const result = await provider.definitions(params);
437
+ assert(result);
438
+ expect(result).toHaveLength(1);
439
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/group-edit.html.liquid');
440
+ });
441
+
442
+ it('navigates when cursor is on the <a tag name with an assigned variable href', async () => {
443
+ setup({
444
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
445
+ });
446
+
447
+ documentManager.open(
448
+ 'file:///project/app/views/pages/home.html.liquid',
449
+ '{% assign url = "/about" %}\n<a href="{{ url }}">About</a>',
450
+ 1,
451
+ );
452
+
453
+ const params: DefinitionParams = {
454
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
455
+ position: { line: 1, character: 1 }, // On the "a" tag name
456
+ };
457
+
458
+ const result = await provider.definitions(params);
459
+ assert(result);
460
+ expect(result).toHaveLength(1);
461
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
462
+ });
463
+
464
+ it('navigates form action with assigned variable', async () => {
465
+ setup({
466
+ 'app/views/pages/contact.html.liquid': '---\nmethod: post\n---\n<h1>Contact</h1>',
467
+ });
468
+
469
+ documentManager.open(
470
+ 'file:///project/app/views/pages/home.html.liquid',
471
+ '{% assign url = "/contact" %}\n<form action="{{ url }}" method="post"><button>Send</button></form>',
472
+ 1,
473
+ );
474
+
475
+ const params: DefinitionParams = {
476
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
477
+ position: { line: 1, character: 16 }, // Inside {{ url }}
478
+ };
479
+
480
+ const result = await provider.definitions(params);
481
+ assert(result);
482
+ expect(result).toHaveLength(1);
483
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/contact.html.liquid');
484
+ });
485
+
486
+ it('returns null for an unresolvable assigned variable', async () => {
487
+ setup({
488
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
489
+ });
490
+
491
+ documentManager.open(
492
+ 'file:///project/app/views/pages/home.html.liquid',
493
+ '{% assign url = some_var | downcase %}\n<a href="{{ url }}">About</a>',
494
+ 1,
495
+ );
496
+
497
+ const params: DefinitionParams = {
498
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
499
+ position: { line: 1, character: 12 }, // Inside {{ url }}
500
+ };
501
+
502
+ const result = await provider.definitions(params);
503
+ assert(result === null);
504
+ });
505
+
506
+ it('navigates when assign is inside a nested block (scope-unaware)', async () => {
507
+ setup({
508
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
509
+ });
510
+
511
+ documentManager.open(
512
+ 'file:///project/app/views/pages/home.html.liquid',
513
+ '{% if true %}{% assign url = "/about" %}{% endif %}\n<a href="{{ url }}">About</a>',
514
+ 1,
515
+ );
516
+
517
+ const params: DefinitionParams = {
518
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
519
+ position: { line: 1, character: 12 }, // Inside {{ url }}
520
+ };
521
+
522
+ const result = await provider.definitions(params);
523
+ assert(result);
524
+ expect(result).toHaveLength(1);
525
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
526
+ });
527
+
528
+ it('navigates when assign and <a> are both inside the same block tag', async () => {
529
+ // Regression test: when buildVariableMap skips recursion into block containers
530
+ // that end after beforeOffset, assigns inside the same block as <a> are missed.
531
+ setup({
532
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
533
+ });
534
+
535
+ const source =
536
+ '{% if true %}{% assign url = "/about" %}<a href="{{ url }}">About</a>{% endif %}';
537
+ documentManager.open('file:///project/app/views/pages/home.html.liquid', source, 1);
538
+
539
+ const urlOffset = source.indexOf('{{ url }}');
540
+ const params: DefinitionParams = {
541
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
542
+ position: { line: 0, character: urlOffset + 3 }, // Inside {{ url }}
543
+ };
544
+
545
+ const result = await provider.definitions(params);
546
+ assert(result);
547
+ expect(result).toHaveLength(1);
548
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
549
+ });
550
+
551
+ it('navigates when assign is inside a {% liquid %} block in the same container', async () => {
552
+ setup({
553
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
554
+ });
555
+
556
+ const source =
557
+ '{% if true %}{% liquid\n assign url = "/about"\n%}<a href="{{ url }}">About</a>{% endif %}';
558
+ documentManager.open('file:///project/app/views/pages/home.html.liquid', source, 1);
559
+
560
+ const urlOffset = source.indexOf('{{ url }}');
561
+ const urlLine = source.slice(0, urlOffset).split('\n').length - 1;
562
+ const urlChar = urlOffset - source.lastIndexOf('\n', urlOffset - 1) - 1;
563
+ const params: DefinitionParams = {
564
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
565
+ position: { line: urlLine, character: urlChar + 3 }, // Inside {{ url }}
566
+ };
567
+
568
+ const result = await provider.definitions(params);
569
+ assert(result);
570
+ expect(result).toHaveLength(1);
571
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
572
+ });
573
+ });
574
+
575
+ describe('format-aware go-to-definition', () => {
576
+ it('navigates to the json page when URL has .json suffix', async () => {
577
+ setup({
578
+ 'app/views/pages/api/my-page.html.liquid': '<h1>HTML</h1>',
579
+ 'app/views/pages/api/my-page.json.liquid': '{ "data": true }',
580
+ });
581
+
582
+ documentManager.open(
583
+ 'file:///project/app/views/pages/home.html.liquid',
584
+ '<a href="/api/my-page.json">JSON</a>',
585
+ 1,
586
+ );
587
+
588
+ const params: DefinitionParams = {
589
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
590
+ position: { line: 0, character: 15 }, // Inside href value
591
+ };
592
+
593
+ const result = await provider.definitions(params);
594
+ assert(result);
595
+ expect(result).toHaveLength(1);
596
+ expect(result[0].targetUri).toContain('my-page.json.liquid');
597
+ });
598
+
599
+ it('navigates to only html page when URL has no format suffix', async () => {
600
+ setup({
601
+ 'app/views/pages/api/my-page.html.liquid': '<h1>HTML</h1>',
602
+ 'app/views/pages/api/my-page.json.liquid': '{ "data": true }',
603
+ });
604
+
605
+ documentManager.open(
606
+ 'file:///project/app/views/pages/home.html.liquid',
607
+ '<a href="/api/my-page">HTML only</a>',
608
+ 1,
609
+ );
610
+
611
+ const params: DefinitionParams = {
612
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
613
+ position: { line: 0, character: 15 }, // Inside href value
614
+ };
615
+
616
+ const result = await provider.definitions(params);
617
+ assert(result);
618
+ expect(result).toHaveLength(1);
619
+ expect(result[0].targetUri).toContain('my-page.html.liquid');
620
+ });
621
+ });
622
+
623
+ describe('assign override — position-aware variable resolution', () => {
624
+ it('first link goes to /about, second link goes to /contact', async () => {
625
+ setup({
626
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
627
+ 'app/views/pages/contact.html.liquid': '<h1>Contact</h1>',
628
+ });
629
+
630
+ // {% assign url = "/about" %}<a href="{{ url }}">..</a>{% assign url = "/contact" %}<a href="{{ url }}">..</a>
631
+ const source =
632
+ '{% assign url = "/about" %}<a href="{{ url }}">About</a>{% assign url = "/contact" %}<a href="{{ url }}">Contact</a>';
633
+ documentManager.open('file:///project/app/views/pages/home.html.liquid', source, 1);
634
+
635
+ // First <a> — cursor inside the first href="{{ url }}"
636
+ // The first <a> starts at offset 27, href value "{{ url }}" is around offset 37
637
+ const firstHrefOffset = source.indexOf('{{ url }}');
638
+ const firstLine = source.slice(0, firstHrefOffset).split('\n').length - 1;
639
+ const firstChar = firstHrefOffset - source.lastIndexOf('\n', firstHrefOffset - 1) - 1;
640
+
641
+ const result1 = await provider.definitions({
642
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
643
+ position: { line: firstLine, character: firstChar + 3 }, // Inside {{ url }}
644
+ });
645
+ assert(result1);
646
+ expect(result1).toHaveLength(1);
647
+ expect(result1[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
648
+
649
+ // Second <a> — cursor inside the second href="{{ url }}"
650
+ const secondHrefOffset = source.indexOf('{{ url }}', firstHrefOffset + 1);
651
+ const secondLine = source.slice(0, secondHrefOffset).split('\n').length - 1;
652
+ const secondChar = secondHrefOffset - source.lastIndexOf('\n', secondHrefOffset - 1) - 1;
653
+
654
+ const result2 = await provider.definitions({
655
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
656
+ position: { line: secondLine, character: secondChar + 3 }, // Inside {{ url }}
657
+ });
658
+ assert(result2);
659
+ expect(result2).toHaveLength(1);
660
+ expect(result2[0].targetUri).toBe('file:///project/app/views/pages/contact.html.liquid');
661
+ });
662
+ });
663
+
664
+ describe('invalidateRouteTable', () => {
665
+ it('forces a full rebuild on next definition request', async () => {
666
+ // Initial setup with /about page
667
+ const files: Record<string, string> = {
668
+ '.pos': '',
669
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
670
+ };
671
+
672
+ const fileUris = new Map<string, string>();
673
+ const dirEntries = new Map<string, [string, FileType][]>();
674
+
675
+ function rebuildIndex() {
676
+ fileUris.clear();
677
+ dirEntries.clear();
678
+ for (const [path, content] of Object.entries(files)) {
679
+ const uri = `file:///project/${path}`;
680
+ fileUris.set(uri, content);
681
+ const parts = path.split('/');
682
+ for (let i = 0; i < parts.length; i++) {
683
+ const dirPath = parts.slice(0, i + 1).join('/');
684
+ const parentPath = parts.slice(0, i).join('/');
685
+ const parentUri = parentPath ? `file:///project/${parentPath}` : 'file:///project';
686
+ const childUri = `file:///project/${dirPath}`;
687
+ const isFile = i === parts.length - 1;
688
+ if (!dirEntries.has(parentUri)) dirEntries.set(parentUri, []);
689
+ const entries = dirEntries.get(parentUri)!;
690
+ if (!entries.some(([u]) => u === childUri)) {
691
+ entries.push([childUri, isFile ? FileType.File : FileType.Directory]);
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ rebuildIndex();
698
+
699
+ const fs: AbstractFileSystem = {
700
+ async stat(uri: string) {
701
+ if (fileUris.has(uri)) return { type: FileType.File, size: fileUris.get(uri)!.length };
702
+ if (dirEntries.has(uri)) return { type: FileType.Directory, size: 0 };
703
+ throw new Error(`ENOENT: ${uri}`);
704
+ },
705
+ async readFile(uri: string) {
706
+ if (fileUris.has(uri)) return fileUris.get(uri)!;
707
+ throw new Error(`ENOENT: ${uri}`);
708
+ },
709
+ async readDirectory(uri: string) {
710
+ if (dirEntries.has(uri)) return dirEntries.get(uri)!;
711
+ throw new Error(`ENOENT: ${uri}`);
712
+ },
713
+ };
714
+
715
+ documentManager = new DocumentManager();
716
+ provider = new DefinitionProvider(
717
+ documentManager,
718
+ async () => null,
719
+ fs,
720
+ async () => 'file:///project',
721
+ );
722
+
723
+ // Open a page that links to /about
724
+ documentManager.open(
725
+ 'file:///project/app/views/pages/home.html.liquid',
726
+ '<a href="/about">About</a>',
727
+ 1,
728
+ );
729
+
730
+ const params: DefinitionParams = {
731
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
732
+ position: { line: 0, character: 12 },
733
+ };
734
+
735
+ // First request triggers build — /about is found
736
+ const result1 = await provider.definitions(params);
737
+ assert(result1);
738
+ expect(result1).toHaveLength(1);
739
+
740
+ // Simulate branch switch: remove /about, add /contact
741
+ delete files['app/views/pages/about.html.liquid'];
742
+ files['app/views/pages/contact.html.liquid'] = '<h1>Contact</h1>';
743
+ rebuildIndex();
744
+
745
+ // Without invalidation, the cached route table still finds /about
746
+ const result2 = await provider.definitions(params);
747
+ assert(result2);
748
+ expect(result2).toHaveLength(1); // Still finds old /about from cache
749
+
750
+ // Invalidate — forces a full rebuild on next request
751
+ provider.invalidateRouteTable();
752
+
753
+ // Now the route table is rebuilt from the new filesystem state
754
+ const result3 = await provider.definitions(params);
755
+ // /about no longer exists, so no definitions found
756
+ expect(result3).toEqual(null);
757
+
758
+ // Verify /contact is now discoverable
759
+ documentManager.open(
760
+ 'file:///project/app/views/pages/home.html.liquid',
761
+ '<a href="/contact">Contact</a>',
762
+ 2,
763
+ );
764
+
765
+ const contactParams: DefinitionParams = {
766
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
767
+ position: { line: 0, character: 14 },
768
+ };
769
+
770
+ const result4 = await provider.definitions(contactParams);
771
+ assert(result4);
772
+ expect(result4).toHaveLength(1);
773
+ expect(result4[0].targetUri).toBe('file:///project/app/views/pages/contact.html.liquid');
774
+ });
775
+ });
776
+ });