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

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 (69) hide show
  1. package/CHANGELOG.md +33 -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/completions/CompletionsProvider.d.ts +1 -0
  10. package/dist/completions/CompletionsProvider.js +6 -0
  11. package/dist/completions/CompletionsProvider.js.map +1 -1
  12. package/dist/completions/providers/GraphQLFieldCompletionProvider.d.ts +12 -0
  13. package/dist/completions/providers/GraphQLFieldCompletionProvider.js +75 -0
  14. package/dist/completions/providers/GraphQLFieldCompletionProvider.js.map +1 -0
  15. package/dist/definitions/DefinitionProvider.d.ts +19 -1
  16. package/dist/definitions/DefinitionProvider.js +33 -1
  17. package/dist/definitions/DefinitionProvider.js.map +1 -1
  18. package/dist/definitions/providers/PageRouteDefinitionProvider.d.ts +51 -0
  19. package/dist/definitions/providers/PageRouteDefinitionProvider.js +204 -0
  20. package/dist/definitions/providers/PageRouteDefinitionProvider.js.map +1 -0
  21. package/dist/definitions/providers/RenderPartialDefinitionProvider.d.ts +14 -0
  22. package/dist/definitions/providers/RenderPartialDefinitionProvider.js +50 -0
  23. package/dist/definitions/providers/RenderPartialDefinitionProvider.js.map +1 -0
  24. package/dist/diagnostics/runChecks.d.ts +3 -1
  25. package/dist/diagnostics/runChecks.js +2 -1
  26. package/dist/diagnostics/runChecks.js.map +1 -1
  27. package/dist/documentLinks/DocumentLinksProvider.d.ts +3 -1
  28. package/dist/documentLinks/DocumentLinksProvider.js +16 -20
  29. package/dist/documentLinks/DocumentLinksProvider.js.map +1 -1
  30. package/dist/hover/HoverProvider.d.ts +1 -0
  31. package/dist/hover/HoverProvider.js +6 -0
  32. package/dist/hover/HoverProvider.js.map +1 -1
  33. package/dist/hover/providers/GraphQLFieldHoverProvider.d.ts +12 -0
  34. package/dist/hover/providers/GraphQLFieldHoverProvider.js +85 -0
  35. package/dist/hover/providers/GraphQLFieldHoverProvider.js.map +1 -0
  36. package/dist/server/AppGraphManager.js +3 -4
  37. package/dist/server/AppGraphManager.js.map +1 -1
  38. package/dist/server/CachedFileSystem.js +7 -2
  39. package/dist/server/CachedFileSystem.js.map +1 -1
  40. package/dist/server/startServer.js +71 -5
  41. package/dist/server/startServer.js.map +1 -1
  42. package/dist/tsconfig.tsbuildinfo +1 -1
  43. package/dist/utils/searchPaths.d.ts +21 -0
  44. package/dist/utils/searchPaths.js +34 -0
  45. package/dist/utils/searchPaths.js.map +1 -0
  46. package/package.json +6 -4
  47. package/src/PropertyShapeInference.spec.ts +44 -0
  48. package/src/PropertyShapeInference.ts +27 -0
  49. package/src/TypeSystem.spec.ts +59 -0
  50. package/src/TypeSystem.ts +119 -58
  51. package/src/VariableShapeExtractor.ts +2 -4
  52. package/src/completions/CompletionsProvider.ts +11 -0
  53. package/src/completions/providers/GraphQLFieldCompletionProvider.ts +78 -0
  54. package/src/definitions/DefinitionProvider.ts +52 -0
  55. package/src/definitions/providers/PageRouteDefinitionProvider.spec.ts +753 -0
  56. package/src/definitions/providers/PageRouteDefinitionProvider.ts +250 -0
  57. package/src/definitions/providers/RenderPartialDefinitionProvider.spec.ts +191 -0
  58. package/src/definitions/providers/RenderPartialDefinitionProvider.ts +70 -0
  59. package/src/diagnostics/runChecks.ts +4 -0
  60. package/src/documentLinks/DocumentLinksProvider.spec.ts +131 -10
  61. package/src/documentLinks/DocumentLinksProvider.ts +23 -32
  62. package/src/hover/HoverProvider.ts +11 -0
  63. package/src/hover/providers/GraphQLFieldHoverProvider.spec.ts +124 -0
  64. package/src/hover/providers/GraphQLFieldHoverProvider.ts +90 -0
  65. package/src/server/AppGraphManager.ts +3 -5
  66. package/src/server/CachedFileSystem.ts +11 -2
  67. package/src/server/startServer.ts +85 -5
  68. package/src/utils/searchPaths.spec.ts +101 -0
  69. package/src/utils/searchPaths.ts +32 -0
@@ -3,28 +3,35 @@ import { DocumentManager } from '../documents';
3
3
  import { DocumentLinksProvider } from './DocumentLinksProvider';
4
4
  import { DocumentsLocator, TranslationProvider } from '@platformos/platformos-common';
5
5
  import { MockFileSystem } from '@platformos/platformos-check-common/src/test';
6
+ import { SearchPathsLoader } from '../utils/searchPaths';
7
+
8
+ function makeProvider(
9
+ documentManager: DocumentManager,
10
+ rootUri: string,
11
+ mockFs: MockFileSystem,
12
+ ): DocumentLinksProvider {
13
+ return new DocumentLinksProvider(
14
+ documentManager,
15
+ async () => rootUri,
16
+ new DocumentsLocator(mockFs),
17
+ new TranslationProvider(mockFs),
18
+ new SearchPathsLoader(mockFs),
19
+ );
20
+ }
6
21
 
7
22
  describe('DocumentLinksProvider', () => {
8
23
  let documentManager: DocumentManager;
9
24
  let documentLinksProvider: DocumentLinksProvider;
10
- let documentsLocator: DocumentsLocator;
11
- let fs: MockFileSystem;
12
25
  let rootUri: string;
13
26
  let uriString: string;
14
27
 
15
28
  beforeEach(() => {
16
29
  documentManager = new DocumentManager();
17
- fs = new MockFileSystem({
30
+ const fs = new MockFileSystem({
18
31
  'path/to/project/app/lib/commands/apply.liquid': 'apply content',
19
32
  'path/to/project/app/views/apply_view.liquid': 'apply view content',
20
33
  });
21
- documentsLocator = new DocumentsLocator(fs);
22
- documentLinksProvider = new DocumentLinksProvider(
23
- documentManager,
24
- async () => rootUri,
25
- documentsLocator,
26
- new TranslationProvider(fs),
27
- );
34
+ documentLinksProvider = makeProvider(documentManager, 'file:///path/to/project', fs);
28
35
  });
29
36
 
30
37
  it('should return an empty array for non-LiquidHtml documents', async () => {
@@ -63,4 +70,118 @@ describe('DocumentLinksProvider', () => {
63
70
  expect(result[i].target).toBe(expectedUrls[i]);
64
71
  }
65
72
  });
73
+
74
+ describe('theme_render_rc', () => {
75
+ it('should resolve theme_render_rc partials via theme_search_paths', async () => {
76
+ rootUri = 'file:///project';
77
+ uriString = 'file:///project/app/views/pages/index.liquid';
78
+
79
+ const mockFs = new MockFileSystem({
80
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
81
+ 'project/app/views/partials/theme/dress/card.liquid': 'dress card',
82
+ 'project/app/views/partials/theme/simple/footer.liquid': 'simple footer',
83
+ });
84
+
85
+ const provider = makeProvider(documentManager, rootUri, mockFs);
86
+
87
+ documentManager.open(
88
+ uriString,
89
+ "{% theme_render_rc 'card' %} {% theme_render_rc 'footer' %}",
90
+ 1,
91
+ );
92
+
93
+ const result = await provider.documentLinks(uriString);
94
+
95
+ expect(result).toHaveLength(2);
96
+ expect(result[0].target).toBe('file:///project/app/views/partials/theme/dress/card.liquid');
97
+ expect(result[1].target).toBe(
98
+ 'file:///project/app/views/partials/theme/simple/footer.liquid',
99
+ );
100
+ });
101
+
102
+ it('should fallback to standard resolution when no config exists', async () => {
103
+ rootUri = 'file:///project';
104
+ uriString = 'file:///project/app/views/pages/index.liquid';
105
+
106
+ const mockFs = new MockFileSystem({
107
+ 'project/app/views/partials/card.liquid': 'default card',
108
+ });
109
+
110
+ const provider = makeProvider(documentManager, rootUri, mockFs);
111
+
112
+ documentManager.open(uriString, "{% theme_render_rc 'card' %}", 1);
113
+
114
+ const result = await provider.documentLinks(uriString);
115
+
116
+ expect(result).toHaveLength(1);
117
+ expect(result[0].target).toBe('file:///project/app/views/partials/card.liquid');
118
+ });
119
+
120
+ it('should resolve theme_render_rc with Liquid wildcard paths', async () => {
121
+ rootUri = 'file:///project';
122
+ uriString = 'file:///project/app/views/pages/index.liquid';
123
+
124
+ const mockFs = new MockFileSystem({
125
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/{{ context.constants.THEME }}',
126
+ 'project/app/views/partials/theme/custom/hero.liquid': 'custom hero',
127
+ });
128
+
129
+ const provider = makeProvider(documentManager, rootUri, mockFs);
130
+
131
+ documentManager.open(uriString, "{% theme_render_rc 'hero' %}", 1);
132
+
133
+ const result = await provider.documentLinks(uriString);
134
+
135
+ expect(result).toHaveLength(1);
136
+ expect(result[0].target).toBe('file:///project/app/views/partials/theme/custom/hero.liquid');
137
+ });
138
+
139
+ it('should not use search paths for regular render tags', async () => {
140
+ rootUri = 'file:///project';
141
+ uriString = 'file:///project/app/views/pages/index.liquid';
142
+
143
+ const mockFs = new MockFileSystem({
144
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/dress',
145
+ 'project/app/views/partials/card.liquid': 'default card',
146
+ });
147
+
148
+ const provider = makeProvider(documentManager, rootUri, mockFs);
149
+
150
+ documentManager.open(uriString, "{% render 'card' %}", 1);
151
+
152
+ const result = await provider.documentLinks(uriString);
153
+
154
+ expect(result).toHaveLength(1);
155
+ expect(result[0].target).toBe('file:///project/app/views/partials/card.liquid');
156
+ });
157
+
158
+ it('should pick up new config after cache invalidation', async () => {
159
+ rootUri = 'file:///project';
160
+ uriString = 'file:///project/app/views/pages/index.liquid';
161
+
162
+ const initialFiles: Record<string, string> = {
163
+ 'project/app/views/partials/card.liquid': 'default card',
164
+ 'project/app/views/partials/theme/new/card.liquid': 'new card',
165
+ };
166
+ const mockFs = new MockFileSystem(initialFiles);
167
+ const provider = makeProvider(documentManager, rootUri, mockFs);
168
+
169
+ documentManager.open(uriString, "{% theme_render_rc 'card' %}", 1);
170
+
171
+ const result1 = await provider.documentLinks(uriString);
172
+ expect(result1).toHaveLength(1);
173
+ expect(result1[0].target).toBe('file:///project/app/views/partials/card.liquid');
174
+
175
+ // Simulate config.yml being created with search paths — new provider with new fs
176
+ const updatedFs = new MockFileSystem({
177
+ ...initialFiles,
178
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/new',
179
+ });
180
+ const provider2 = makeProvider(documentManager, rootUri, updatedFs);
181
+
182
+ const result2 = await provider2.documentLinks(uriString);
183
+ expect(result2).toHaveLength(1);
184
+ expect(result2[0].target).toBe('file:///project/app/views/partials/theme/new/card.liquid');
185
+ });
186
+ });
66
187
  });
@@ -1,13 +1,14 @@
1
- import { LiquidHtmlNode, LiquidString, NamedTags, NodeTypes } from '@platformos/liquid-html-parser';
1
+ import { LiquidHtmlNode, LiquidString, NodeTypes } from '@platformos/liquid-html-parser';
2
2
  import { SourceCodeType } from '@platformos/platformos-check-common';
3
3
  import { DocumentLink, Range } from 'vscode-languageserver';
4
4
  import { TextDocument } from 'vscode-languageserver-textdocument';
5
- import { URI, Utils } from 'vscode-uri';
5
+ import { URI } from 'vscode-uri';
6
6
 
7
7
  import { visit, Visitor } from '@platformos/platformos-check-common';
8
8
  import { DocumentManager } from '../documents';
9
9
  import { FindAppRootURI } from '../internal-types';
10
- import { DocumentsLocator, TranslationProvider } from '@platformos/platformos-common';
10
+ import { DocumentsLocator, DocumentType, TranslationProvider } from '@platformos/platformos-common';
11
+ import { SearchPathsLoader } from '../utils/searchPaths';
11
12
 
12
13
  export class DocumentLinksProvider {
13
14
  constructor(
@@ -15,6 +16,7 @@ export class DocumentLinksProvider {
15
16
  private findAppRootURI: FindAppRootURI,
16
17
  private documentsLocator: DocumentsLocator,
17
18
  private translationProvider: TranslationProvider,
19
+ private searchPathsCache: SearchPathsLoader,
18
20
  ) {}
19
21
 
20
22
  async documentLinks(uriString: string): Promise<DocumentLink[]> {
@@ -32,11 +34,15 @@ export class DocumentLinksProvider {
32
34
  return [];
33
35
  }
34
36
 
37
+ const root = URI.parse(rootUri);
38
+ const searchPaths = await this.searchPathsCache.get(root);
39
+
35
40
  const visitor = documentLinksVisitor(
36
41
  sourceCode.textDocument,
37
- URI.parse(rootUri),
42
+ root,
38
43
  this.documentsLocator,
39
44
  this.translationProvider,
45
+ searchPaths,
40
46
  );
41
47
  return visit(sourceCode.ast, visitor);
42
48
  }
@@ -47,43 +53,28 @@ function documentLinksVisitor(
47
53
  root: URI,
48
54
  documentsLocator: DocumentsLocator,
49
55
  translationProvider: TranslationProvider,
56
+ searchPaths: string[] | null,
50
57
  ): Visitor<SourceCodeType.LiquidHtml, DocumentLink> {
51
58
  return {
52
59
  async LiquidTag(node) {
53
- if (
54
- (node.name === 'render' || node.name === 'include') &&
55
- typeof node.markup !== 'string' &&
56
- isLiquidString(node.markup.partial)
57
- ) {
58
- const partial = node.markup.partial;
59
- return DocumentLink.create(
60
- range(textDocument, partial),
61
- await documentsLocator.locate(root, node.name, partial.value),
62
- );
63
- }
60
+ const markup = node.markup;
61
+ if (typeof markup === 'string' || markup === null) return;
62
+
63
+ const name = node.name as DocumentType;
64
64
 
65
- if (
66
- node.name === 'function' &&
67
- typeof node.markup !== 'string' &&
68
- isLiquidString(node.markup.partial)
69
- ) {
70
- const partial = node.markup.partial;
65
+ // render, include, function, theme_render_rc all have a .partial field
66
+ if ('partial' in markup && isLiquidString(markup.partial)) {
71
67
  return DocumentLink.create(
72
- range(textDocument, partial),
73
- await documentsLocator.locate(root, node.name, partial.value),
68
+ range(textDocument, markup.partial),
69
+ await documentsLocator.locate(root, name, markup.partial.value, searchPaths),
74
70
  );
75
71
  }
76
72
 
77
- if (
78
- node.name === 'graphql' &&
79
- typeof node.markup !== 'string' &&
80
- 'graphql' in node.markup &&
81
- isLiquidString(node.markup.graphql)
82
- ) {
83
- const snippet = node.markup.graphql;
73
+ // graphql has a .graphql field
74
+ if ('graphql' in markup && isLiquidString(markup.graphql)) {
84
75
  return DocumentLink.create(
85
- range(textDocument, snippet),
86
- await documentsLocator.locate(root, node.name, snippet.value),
76
+ range(textDocument, markup.graphql),
77
+ await documentsLocator.locate(root, name, markup.graphql.value),
87
78
  );
88
79
  }
89
80
  },
@@ -23,10 +23,12 @@ import {
23
23
  import { HtmlAttributeValueHoverProvider } from './providers/HtmlAttributeValueHoverProvider';
24
24
  import { findCurrentNode } from '@platformos/platformos-check-common';
25
25
  import { LiquidDocTagHoverProvider } from './providers/LiquidDocTagHoverProvider';
26
+ import { GraphQLFieldHoverProvider } from './providers/GraphQLFieldHoverProvider';
26
27
  import { TranslationProvider } from '@platformos/platformos-common';
27
28
  import { FindAppRootURI } from '../../src/internal-types';
28
29
  export class HoverProvider {
29
30
  private providers: BaseHoverProvider[] = [];
31
+ private graphqlFieldHoverProvider: GraphQLFieldHoverProvider;
30
32
 
31
33
  constructor(
32
34
  readonly documentManager: DocumentManager,
@@ -37,6 +39,10 @@ export class HoverProvider {
37
39
  readonly findAppRootURI: FindAppRootURI = async () => null,
38
40
  ) {
39
41
  const typeSystem = new TypeSystem(platformosDocset);
42
+ this.graphqlFieldHoverProvider = new GraphQLFieldHoverProvider(
43
+ platformosDocset,
44
+ documentManager,
45
+ );
40
46
  this.providers = [
41
47
  new LiquidTagHoverProvider(platformosDocset),
42
48
  new LiquidFilterArgumentHoverProvider(platformosDocset),
@@ -57,6 +63,11 @@ export class HoverProvider {
57
63
  const uri = params.textDocument.uri;
58
64
  const document = this.documentManager.get(uri);
59
65
 
66
+ // GraphQL files get dedicated hover support
67
+ if (document?.type === SourceCodeType.GraphQL) {
68
+ return this.graphqlFieldHoverProvider.hover(params);
69
+ }
70
+
60
71
  // Supports only Liquid resources
61
72
  if (document?.type !== SourceCodeType.LiquidHtml || document.ast instanceof Error) {
62
73
  return null;
@@ -0,0 +1,124 @@
1
+ import { describe, beforeEach, it, expect } from 'vitest';
2
+ import { DocumentManager } from '../../documents';
3
+ import { HoverProvider } from '../HoverProvider';
4
+ import { TranslationProvider } from '@platformos/platformos-common';
5
+ import { MockFileSystem } from '@platformos/platformos-check-common/src/test';
6
+ import { HoverParams } from 'vscode-languageserver';
7
+
8
+ const SCHEMA = `
9
+ type Query {
10
+ records: RecordList
11
+ user(id: ID!): User
12
+ }
13
+
14
+ type RecordList {
15
+ results: [Record]
16
+ total_entries: Int
17
+ }
18
+
19
+ type Record {
20
+ id: ID
21
+ name: String
22
+ email: String
23
+ }
24
+
25
+ type User {
26
+ id: ID
27
+ name: String
28
+ }
29
+ `;
30
+
31
+ describe('Module: GraphQLFieldHoverProvider', () => {
32
+ let provider: HoverProvider;
33
+ let documentManager: DocumentManager;
34
+
35
+ beforeEach(() => {
36
+ documentManager = new DocumentManager();
37
+ provider = new HoverProvider(
38
+ documentManager,
39
+ {
40
+ graphQL: async () => SCHEMA,
41
+ filters: async () => [],
42
+ objects: async () => [],
43
+ liquidDrops: async () => [],
44
+ tags: async () => [],
45
+ },
46
+ new TranslationProvider(new MockFileSystem({})),
47
+ );
48
+ });
49
+
50
+ it('should return hover info for a top-level field in a .graphql file', async () => {
51
+ const source = '{\n records {\n total_entries\n }\n}';
52
+ const uri = 'file:///app/graphql/test.graphql';
53
+ documentManager.open(uri, source, 0);
54
+
55
+ const params: HoverParams = {
56
+ position: { line: 1, character: 5 },
57
+ textDocument: { uri },
58
+ };
59
+
60
+ const result = await provider.hover(params);
61
+ expect(result).not.toBeNull();
62
+ expect((result!.contents as any).value).toContain('records');
63
+ expect((result!.contents as any).value).toContain('RecordList');
64
+ });
65
+
66
+ it('should return hover info for a nested field', async () => {
67
+ const source = 'query {\n records {\n results {\n name\n }\n }\n}';
68
+ const uri = 'file:///app/graphql/nested.graphql';
69
+ documentManager.open(uri, source, 0);
70
+
71
+ const params: HoverParams = {
72
+ position: { line: 3, character: 8 },
73
+ textDocument: { uri },
74
+ };
75
+
76
+ const result = await provider.hover(params);
77
+ expect(result).not.toBeNull();
78
+ expect((result!.contents as any).value).toContain('Record.name');
79
+ expect((result!.contents as any).value).toContain('String');
80
+ });
81
+
82
+ it('should return null when no schema is available', async () => {
83
+ const noSchemaProvider = new HoverProvider(
84
+ documentManager,
85
+ {
86
+ graphQL: async () => null,
87
+ filters: async () => [],
88
+ objects: async () => [],
89
+ liquidDrops: async () => [],
90
+ tags: async () => [],
91
+ },
92
+ new TranslationProvider(new MockFileSystem({})),
93
+ );
94
+
95
+ const source = 'query {\n records {\n results {\n name\n }\n }\n}';
96
+ const uri = 'file:///app/graphql/test.graphql';
97
+ documentManager.open(uri, source, 0);
98
+
99
+ const params: HoverParams = {
100
+ position: { line: 1, character: 5 },
101
+ textDocument: { uri },
102
+ };
103
+
104
+ const result = await noSchemaProvider.hover(params);
105
+ expect(result).toBeNull();
106
+ });
107
+
108
+ it('should not interfere with .liquid file hover', async () => {
109
+ const source = '{{ product }}';
110
+ const uri = 'file:///app/views/partials/test.liquid';
111
+ documentManager.open(uri, source, 0);
112
+
113
+ const textDocument = documentManager.get(uri)!.textDocument;
114
+ const params: HoverParams = {
115
+ position: textDocument.positionAt(source.indexOf('product')),
116
+ textDocument: { uri },
117
+ };
118
+
119
+ // Should not crash — goes through normal Liquid pipeline
120
+ const result = await provider.hover(params);
121
+ // product is not in the docset, so hover returns null
122
+ expect(result).toBeNull();
123
+ });
124
+ });
@@ -0,0 +1,90 @@
1
+ import { Hover, HoverParams } from 'vscode-languageserver';
2
+ import { DocumentManager } from '../../documents';
3
+ import { PlatformOSDocset } from '@platformos/platformos-check-common';
4
+
5
+ /**
6
+ * graphql-language-service and graphql must be loaded dynamically to avoid
7
+ * the "Cannot use GraphQLList from another module or realm" error that occurs
8
+ * when vitest transforms the graphql ESM module into separate instances.
9
+ * By requiring at runtime, we ensure a single shared module instance.
10
+ */
11
+ let _glsMod: typeof import('graphql-language-service') | undefined;
12
+ function getGLS(): typeof import('graphql-language-service') {
13
+ if (!_glsMod) _glsMod = require('graphql-language-service');
14
+ return _glsMod!;
15
+ }
16
+
17
+ let _graphqlMod: typeof import('graphql') | undefined;
18
+ function getGraphQL(): typeof import('graphql') {
19
+ if (!_graphqlMod) _graphqlMod = require('graphql');
20
+ return _graphqlMod!;
21
+ }
22
+
23
+ export class GraphQLFieldHoverProvider {
24
+ private schemaCache: any;
25
+ private schemaLoaded = false;
26
+
27
+ constructor(
28
+ private platformosDocset: PlatformOSDocset,
29
+ private documentManager: DocumentManager,
30
+ ) {}
31
+
32
+ private async getSchema(): Promise<any> {
33
+ if (!this.schemaLoaded) {
34
+ const sdl = await this.platformosDocset.graphQL();
35
+ if (sdl) {
36
+ try {
37
+ this.schemaCache = getGraphQL().buildSchema(sdl);
38
+ } catch {
39
+ // Invalid schema SDL
40
+ }
41
+ }
42
+ this.schemaLoaded = true;
43
+ }
44
+ return this.schemaCache;
45
+ }
46
+
47
+ async hover(params: HoverParams): Promise<Hover | null> {
48
+ const uri = params.textDocument.uri;
49
+ const document = this.documentManager.get(uri);
50
+ if (!document) return null;
51
+
52
+ const schema = await this.getSchema();
53
+ if (!schema) return null;
54
+
55
+ const content = document.textDocument.getText();
56
+ const gls = getGLS();
57
+
58
+ // graphql-language-service's getHoverInformation uses the character *before* the cursor
59
+ // position to identify the token. We try offset +1 first, then the original position,
60
+ // to handle the case where the cursor is at the start of a token.
61
+ const positions = [
62
+ new gls.Position(params.position.line, params.position.character + 1),
63
+ new gls.Position(params.position.line, params.position.character),
64
+ ];
65
+
66
+ try {
67
+ let hoverInfo: string | undefined;
68
+ for (const position of positions) {
69
+ const info = gls.getHoverInformation(schema, content, position);
70
+ if (info && info !== '' && info !== 'null') {
71
+ hoverInfo = typeof info === 'string' ? info : String(info);
72
+ break;
73
+ }
74
+ }
75
+
76
+ if (!hoverInfo) {
77
+ return null;
78
+ }
79
+
80
+ return {
81
+ contents: {
82
+ kind: 'markdown',
83
+ value: hoverInfo,
84
+ },
85
+ };
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+ }
@@ -23,7 +23,6 @@ import { FindAppRootURI } from '../internal-types';
23
23
 
24
24
  export class AppGraphManager {
25
25
  graphs: Map<string, ReturnType<typeof buildAppGraph>> = new Map();
26
-
27
26
  constructor(
28
27
  private connection: Connection,
29
28
  private documentManager: DocumentManager,
@@ -153,9 +152,7 @@ export class AppGraphManager {
153
152
  const rootUri = await this.findAppRootURI(anyUri);
154
153
  if (!rootUri) return;
155
154
 
156
- const graph = await this.graphs.get(rootUri);
157
- if (!graph) return;
158
-
155
+ // Delete existing graph to force rebuild
159
156
  this.graphs.delete(rootUri);
160
157
  await this.getAppGraphForURI(rootUri);
161
158
  this.connection.sendNotification(AppGraphDidUpdateNotification.type, { uri: rootUri });
@@ -166,7 +163,8 @@ export class AppGraphManager {
166
163
  await documentManager.preload(rootUri);
167
164
 
168
165
  const dependencies = await this.graphDependencies(rootUri);
169
- return buildAppGraph(rootUri, dependencies, entryPoints);
166
+ const graph = await buildAppGraph(rootUri, dependencies, entryPoints);
167
+ return graph;
170
168
  };
171
169
 
172
170
  private getSourceCode = async (uri: string) => {
@@ -6,7 +6,12 @@ export class CachedFileSystem implements AbstractFileSystem {
6
6
  stat: Cached<AbstractFileSystem['stat']>;
7
7
 
8
8
  constructor(fs: AbstractFileSystem) {
9
- this.readFile = cachedByUri(fs.readFile.bind(fs));
9
+ this.readFile = cachedByUri(
10
+ fs.readFile.bind(fs),
11
+ // app/config.yml can change externally (e.g. vim) without a file watcher
12
+ // notification. Always read it fresh — it's small and rarely accessed.
13
+ (uri) => uri.endsWith('/app/config.yml'),
14
+ );
10
15
  this.readDirectory = cachedByUri(fs.readDirectory.bind(fs));
11
16
  this.stat = cachedByUri(fs.stat.bind(fs));
12
17
  }
@@ -17,10 +22,14 @@ interface Cached<Fn extends (uri: string) => Promise<any>, T = ReturnType<Fn>> {
17
22
  invalidate(uri: string): void;
18
23
  }
19
24
 
20
- function cachedByUri<T>(fn: (uri: string) => Promise<T>): Cached<typeof fn> {
25
+ function cachedByUri<T>(
26
+ fn: (uri: string) => Promise<T>,
27
+ skipCache?: (uri: string) => boolean,
28
+ ): Cached<typeof fn> {
21
29
  const cache = new Map<string, Promise<T>>();
22
30
 
23
31
  function cached(uri: string) {
32
+ if (skipCache?.(uri)) return fn(uri);
24
33
  if (!cache.has(uri)) {
25
34
  // I'm intentionally leaving this comment here for debugging purposes :)
26
35
  // console.error('cache miss', fn.name, uri);