@platformos/platformos-language-server-common 0.0.13 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformos/platformos-language-server-common",
3
- "version": "0.0.13",
3
+ "version": "0.0.16",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "author": "platformOS",
@@ -27,9 +27,9 @@
27
27
  "type-check": "tsc --noEmit -p ./tsconfig.json"
28
28
  },
29
29
  "dependencies": {
30
- "@platformos/liquid-html-parser": "^0.0.12",
31
- "@platformos/platformos-check-common": "0.0.13",
32
- "@platformos/platformos-graph": "0.0.13",
30
+ "@platformos/liquid-html-parser": "^0.0.15",
31
+ "@platformos/platformos-check-common": "0.0.16",
32
+ "@platformos/platformos-graph": "0.0.16",
33
33
  "@vscode/web-custom-data": "^0.4.6",
34
34
  "graphql": "^16.12.0",
35
35
  "graphql-language-service": "^5.2.2",
@@ -547,6 +547,29 @@ describe('Module: PageRouteDefinitionProvider', () => {
547
547
  expect(result).toHaveLength(1);
548
548
  expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
549
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
+ });
550
573
  });
551
574
 
552
575
  describe('format-aware go-to-definition', () => {
@@ -48,13 +48,17 @@ describe('RenderPartialDefinitionProvider', () => {
48
48
  expect(result[0].targetUri).toBe('file:///project/app/views/partials/card.liquid');
49
49
  });
50
50
 
51
- it('should NOT use search paths for regular render tags', async () => {
51
+ it('should NOT use search paths for regular render tags — falls back to default creation path', async () => {
52
+ // The file only exists under a search path (theme/dress/card.liquid), not the standard path.
53
+ // render should NOT pick up search-path files; instead it falls back to the default
54
+ // app/views/partials/card.liquid creation path.
52
55
  const result = await getDefinitions("{% render 'card' %}", 12, {
53
56
  'project/app/config.yml': 'theme_search_paths:\n - theme/dress',
54
57
  'project/app/views/partials/theme/dress/card.liquid': 'dress card',
55
58
  });
56
59
 
57
- expect(result).toHaveLength(0);
60
+ expect(result).toHaveLength(1);
61
+ expect(result[0].targetUri).toBe('file:///project/app/views/partials/card.liquid');
58
62
  });
59
63
  });
60
64
 
@@ -130,6 +134,37 @@ describe('RenderPartialDefinitionProvider', () => {
130
134
  });
131
135
  });
132
136
 
137
+ describe('missing files — fall back to default path', () => {
138
+ it('render: missing partial resolves to app/views/partials', async () => {
139
+ const result = await getDefinitions("{% render 'my/missing/partial' %}", 12, {});
140
+
141
+ expect(result).toHaveLength(1);
142
+ expect(result[0].targetUri).toBe(
143
+ 'file:///project/app/views/partials/my/missing/partial.liquid',
144
+ );
145
+ });
146
+
147
+ it('function: missing partial resolves to app/lib', async () => {
148
+ const result = await getDefinitions("{% function result = 'commands/missing' %}", 24, {});
149
+
150
+ expect(result).toHaveLength(1);
151
+ expect(result[0].targetUri).toBe('file:///project/app/lib/commands/missing.liquid');
152
+ });
153
+
154
+ it('graphql: missing file resolves to app/graphql', async () => {
155
+ const result = await getDefinitions("{% graphql g = 'users/missing' %}", 18, {});
156
+
157
+ expect(result).toHaveLength(1);
158
+ expect(result[0].targetUri).toBe('file:///project/app/graphql/users/missing.graphql');
159
+ });
160
+
161
+ it('theme_render_rc: missing file returns empty (no default path)', async () => {
162
+ const result = await getDefinitions("{% theme_render_rc 'missing' %}", 20, {});
163
+
164
+ expect(result).toHaveLength(0);
165
+ });
166
+ });
167
+
133
168
  describe('non-matching nodes', () => {
134
169
  it('should return empty for non-string nodes', async () => {
135
170
  const result = await getDefinitions("{% assign x = 'hello' %}", 3, {});
@@ -47,7 +47,7 @@ export class RenderPartialDefinitionProvider implements BaseDefinitionProvider {
47
47
  const root = URI.parse(rootUri);
48
48
  const searchPaths = await this.searchPathsCache.get(root);
49
49
  const docType = (tag as LiquidTag).name as DocumentType;
50
- const fileUri = await this.documentsLocator.locate(
50
+ const fileUri = await this.documentsLocator.locateOrDefault(
51
51
  root,
52
52
  docType,
53
53
  (node as LiquidString).value,
@@ -64,18 +64,19 @@ function documentLinksVisitor(
64
64
 
65
65
  // render, include, function, theme_render_rc all have a .partial field
66
66
  if ('partial' in markup && isLiquidString(markup.partial)) {
67
- return DocumentLink.create(
68
- range(textDocument, markup.partial),
69
- await documentsLocator.locate(root, name, markup.partial.value, searchPaths),
67
+ const uri = await documentsLocator.locateOrDefault(
68
+ root,
69
+ name,
70
+ markup.partial.value,
71
+ searchPaths,
70
72
  );
73
+ return DocumentLink.create(range(textDocument, markup.partial), uri);
71
74
  }
72
75
 
73
76
  // graphql has a .graphql field
74
77
  if ('graphql' in markup && isLiquidString(markup.graphql)) {
75
- return DocumentLink.create(
76
- range(textDocument, markup.graphql),
77
- await documentsLocator.locate(root, name, markup.graphql.value),
78
- );
78
+ const uri = await documentsLocator.locateOrDefault(root, name, markup.graphql.value);
79
+ return DocumentLink.create(range(textDocument, markup.graphql), uri);
79
80
  }
80
81
  },
81
82
  async LiquidVariable(node) {
@@ -7,11 +7,13 @@ import {
7
7
  DidRenameFilesNotification,
8
8
  FileChangeType,
9
9
  PublishDiagnosticsNotification,
10
+ DefinitionRequest,
10
11
  } from 'vscode-languageserver';
11
12
  import { MockConnection, mockConnection } from '../test/MockConnection';
12
13
  import { Dependencies } from '../types';
13
14
  import { CHECK_ON_CHANGE, CHECK_ON_OPEN, CHECK_ON_SAVE } from './Configuration';
14
15
  import { startServer } from './startServer';
16
+ import { SearchPathsLoader } from '../utils/searchPaths';
15
17
 
16
18
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
17
19
 
@@ -76,6 +78,7 @@ describe('Module: server', () => {
76
78
 
77
79
  afterEach(() => {
78
80
  vi.useRealTimers();
81
+ vi.restoreAllMocks();
79
82
  });
80
83
 
81
84
  it("should log Let's roll! on successful setup", async () => {
@@ -315,6 +318,65 @@ describe('Module: server', () => {
315
318
  );
316
319
  });
317
320
 
321
+ it('go-to-definition reflects updated search paths after saving app/config.yml', async () => {
322
+ // Setup file tree: config pointing to theme/dress, both dress and simple partials present
323
+ fileTree['app/config.yml'] = 'theme_search_paths:\n - theme/dress';
324
+ fileTree['app/views/partials/theme/dress/card.liquid'] = 'dress card';
325
+ fileTree['app/views/partials/theme/simple/card.liquid'] = 'simple card';
326
+
327
+ connection.setup();
328
+ await flushAsync();
329
+
330
+ // Open a document referencing the partial
331
+ const source = "{% theme_render_rc 'card' %}";
332
+ connection.openDocument(filePath, source);
333
+ await flushAsync();
334
+
335
+ // Request definition — character 21 is inside 'card'
336
+ const params = {
337
+ textDocument: { uri: fileURI },
338
+ position: { line: 0, character: 21 },
339
+ };
340
+ const result1 = (await connection.triggerRequest(DefinitionRequest.method, params)) as any[];
341
+ expect(result1).toHaveLength(1);
342
+ expect(result1[0].targetUri).toContain('theme/dress/card.liquid');
343
+
344
+ // Mutate config to point to theme/simple, then save the config file
345
+ fileTree['app/config.yml'] = 'theme_search_paths:\n - theme/simple';
346
+ connection.saveDocument('app/config.yml');
347
+ await flushAsync();
348
+
349
+ // Definition should now resolve to theme/simple
350
+ const result2 = (await connection.triggerRequest(DefinitionRequest.method, params)) as any[];
351
+ expect(result2).toHaveLength(1);
352
+ expect(result2[0].targetUri).toContain('theme/simple/card.liquid');
353
+ });
354
+
355
+ it('should invalidate search-paths cache immediately when app/config.yml is saved', async () => {
356
+ connection.setup();
357
+ await flushAsync();
358
+
359
+ const invalidateSpy = vi.spyOn(SearchPathsLoader.prototype, 'invalidate');
360
+
361
+ // Saving app/config.yml should immediately invalidate the cache
362
+ connection.saveDocument('app/config.yml');
363
+ await flushAsync();
364
+
365
+ expect(invalidateSpy).toHaveBeenCalledOnce();
366
+ });
367
+
368
+ it('should NOT invalidate search-paths cache when an unrelated file is saved', async () => {
369
+ connection.setup();
370
+ await flushAsync();
371
+
372
+ const invalidateSpy = vi.spyOn(SearchPathsLoader.prototype, 'invalidate');
373
+
374
+ connection.saveDocument('app/views/partials/code.liquid');
375
+ await flushAsync();
376
+
377
+ expect(invalidateSpy).not.toHaveBeenCalled();
378
+ });
379
+
318
380
  it('should trigger a re-check on did delete files notifications', async () => {
319
381
  connection.setup();
320
382
  await flushAsync();
@@ -2,6 +2,7 @@ import { vi } from 'vitest';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { createConnection } from 'vscode-languageserver/lib/common/server';
4
4
  import {
5
+ CancellationToken,
5
6
  ClientCapabilities,
6
7
  DidChangeTextDocumentNotification,
7
8
  DidCloseTextDocumentNotification,
@@ -47,7 +48,11 @@ type MockConnectionMethods = {
47
48
  */
48
49
  export type MockConnection = ReturnType<typeof createConnection> & MockConnectionMethods;
49
50
 
50
- function protocolConnection(requests: EventEmitter, notifications: EventEmitter) {
51
+ function protocolConnection(
52
+ requests: EventEmitter,
53
+ notifications: EventEmitter,
54
+ requestHandlers: Map<string, Function>,
55
+ ) {
51
56
  return {
52
57
  dispose: vi.fn(),
53
58
  end: vi.fn(),
@@ -61,12 +66,13 @@ function protocolConnection(requests: EventEmitter, notifications: EventEmitter)
61
66
  notifications.addListener(type.method, handler);
62
67
  }),
63
68
  onRequest: vi.fn().mockImplementation((type: MessageSignature, handler) => {
69
+ requestHandlers.set(type.method, handler);
64
70
  requests.addListener(type.method, handler);
65
71
  }),
66
72
  onUnhandledNotification: vi.fn(),
67
73
  sendNotification: vi.fn().mockReturnValue(Promise.resolve()),
68
74
  sendProgress: vi.fn(),
69
- sendRequest: vi.fn(),
75
+ sendRequest: vi.fn().mockReturnValue(Promise.resolve()),
70
76
  trace: vi.fn().mockReturnValue(Promise.resolve()),
71
77
  } satisfies ProtocolConnection;
72
78
  }
@@ -80,7 +86,8 @@ export function mockConnection(rootUri: string): MockConnection {
80
86
 
81
87
  const requests = new EventEmitter();
82
88
  const notifications = new EventEmitter();
83
- const spies = protocolConnection(requests, notifications);
89
+ const requestHandlers = new Map<string, Function>();
90
+ const spies = protocolConnection(requests, notifications, requestHandlers);
84
91
 
85
92
  // Create a real "connection" with the fake communication channel
86
93
  const connection = createConnection(() => spies, watchDog);
@@ -92,10 +99,15 @@ export function mockConnection(rootUri: string): MockConnection {
92
99
  notifications.emit(method, params);
93
100
  };
94
101
 
95
- // Create a mock way to trigger requests in our tests
102
+ // Create a mock way to trigger requests in our tests and get the response back.
103
+ // Calls the registered handler directly so that the return value is available.
96
104
  const triggerRequest: MockConnection['triggerRequest'] = async (...args: any[]) => {
97
105
  const [type, params] = args;
98
106
  const method = typeof type === 'string' ? type : type.method;
107
+ const handler = requestHandlers.get(method);
108
+ if (handler) {
109
+ return handler(params, CancellationToken.None);
110
+ }
99
111
  requests.emit(method, params);
100
112
  };
101
113