@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.
- package/CHANGELOG.md +33 -0
- package/dist/PropertyShapeInference.d.ts +5 -0
- package/dist/PropertyShapeInference.js +28 -0
- package/dist/PropertyShapeInference.js.map +1 -1
- package/dist/TypeSystem.js +106 -55
- package/dist/TypeSystem.js.map +1 -1
- package/dist/VariableShapeExtractor.js +2 -4
- package/dist/VariableShapeExtractor.js.map +1 -1
- package/dist/completions/CompletionsProvider.d.ts +1 -0
- package/dist/completions/CompletionsProvider.js +6 -0
- package/dist/completions/CompletionsProvider.js.map +1 -1
- package/dist/completions/providers/GraphQLFieldCompletionProvider.d.ts +12 -0
- package/dist/completions/providers/GraphQLFieldCompletionProvider.js +75 -0
- package/dist/completions/providers/GraphQLFieldCompletionProvider.js.map +1 -0
- package/dist/definitions/DefinitionProvider.d.ts +19 -1
- package/dist/definitions/DefinitionProvider.js +33 -1
- package/dist/definitions/DefinitionProvider.js.map +1 -1
- package/dist/definitions/providers/PageRouteDefinitionProvider.d.ts +51 -0
- package/dist/definitions/providers/PageRouteDefinitionProvider.js +204 -0
- package/dist/definitions/providers/PageRouteDefinitionProvider.js.map +1 -0
- package/dist/definitions/providers/RenderPartialDefinitionProvider.d.ts +14 -0
- package/dist/definitions/providers/RenderPartialDefinitionProvider.js +50 -0
- package/dist/definitions/providers/RenderPartialDefinitionProvider.js.map +1 -0
- package/dist/diagnostics/runChecks.d.ts +3 -1
- package/dist/diagnostics/runChecks.js +2 -1
- package/dist/diagnostics/runChecks.js.map +1 -1
- package/dist/documentLinks/DocumentLinksProvider.d.ts +3 -1
- package/dist/documentLinks/DocumentLinksProvider.js +16 -20
- package/dist/documentLinks/DocumentLinksProvider.js.map +1 -1
- package/dist/hover/HoverProvider.d.ts +1 -0
- package/dist/hover/HoverProvider.js +6 -0
- package/dist/hover/HoverProvider.js.map +1 -1
- package/dist/hover/providers/GraphQLFieldHoverProvider.d.ts +12 -0
- package/dist/hover/providers/GraphQLFieldHoverProvider.js +85 -0
- package/dist/hover/providers/GraphQLFieldHoverProvider.js.map +1 -0
- package/dist/server/AppGraphManager.js +3 -4
- package/dist/server/AppGraphManager.js.map +1 -1
- package/dist/server/CachedFileSystem.js +7 -2
- package/dist/server/CachedFileSystem.js.map +1 -1
- package/dist/server/startServer.js +71 -5
- package/dist/server/startServer.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/searchPaths.d.ts +21 -0
- package/dist/utils/searchPaths.js +34 -0
- package/dist/utils/searchPaths.js.map +1 -0
- package/package.json +6 -4
- package/src/PropertyShapeInference.spec.ts +44 -0
- package/src/PropertyShapeInference.ts +27 -0
- package/src/TypeSystem.spec.ts +59 -0
- package/src/TypeSystem.ts +119 -58
- package/src/VariableShapeExtractor.ts +2 -4
- package/src/completions/CompletionsProvider.ts +11 -0
- package/src/completions/providers/GraphQLFieldCompletionProvider.ts +78 -0
- package/src/definitions/DefinitionProvider.ts +52 -0
- package/src/definitions/providers/PageRouteDefinitionProvider.spec.ts +753 -0
- package/src/definitions/providers/PageRouteDefinitionProvider.ts +250 -0
- package/src/definitions/providers/RenderPartialDefinitionProvider.spec.ts +191 -0
- package/src/definitions/providers/RenderPartialDefinitionProvider.ts +70 -0
- package/src/diagnostics/runChecks.ts +4 -0
- package/src/documentLinks/DocumentLinksProvider.spec.ts +131 -10
- package/src/documentLinks/DocumentLinksProvider.ts +23 -32
- package/src/hover/HoverProvider.ts +11 -0
- package/src/hover/providers/GraphQLFieldHoverProvider.spec.ts +124 -0
- package/src/hover/providers/GraphQLFieldHoverProvider.ts +90 -0
- package/src/server/AppGraphManager.ts +3 -5
- package/src/server/CachedFileSystem.ts +11 -2
- package/src/server/startServer.ts +85 -5
- package/src/utils/searchPaths.spec.ts +101 -0
- 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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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,
|
|
68
|
+
range(textDocument, markup.partial),
|
|
69
|
+
await documentsLocator.locate(root, name, markup.partial.value, searchPaths),
|
|
74
70
|
);
|
|
75
71
|
}
|
|
76
72
|
|
|
77
|
-
|
|
78
|
-
|
|
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,
|
|
86
|
-
await documentsLocator.locate(root,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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>(
|
|
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);
|