@platformos/platformos-language-server-common 0.0.17 → 0.0.18
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 +10 -0
- package/dist/TypeSystem.js +3 -9
- package/dist/TypeSystem.js.map +1 -1
- package/dist/codeActions/CodeActionsProvider.d.ts +1 -1
- package/dist/completions/CompletionsProvider.d.ts +8 -3
- package/dist/completions/CompletionsProvider.js +100 -1
- package/dist/completions/CompletionsProvider.js.map +1 -1
- package/dist/completions/params/LiquidCompletionParams.js +1 -2
- package/dist/completions/params/LiquidCompletionParams.js.map +1 -1
- package/dist/completions/providers/FrontmatterKeyCompletionProvider.d.ts +14 -0
- package/dist/completions/providers/FrontmatterKeyCompletionProvider.js +149 -0
- package/dist/completions/providers/FrontmatterKeyCompletionProvider.js.map +1 -0
- package/dist/completions/providers/index.d.ts +1 -0
- package/dist/completions/providers/index.js +3 -1
- package/dist/completions/providers/index.js.map +1 -1
- package/dist/definitions/DefinitionProvider.js +2 -0
- package/dist/definitions/DefinitionProvider.js.map +1 -1
- package/dist/definitions/providers/FrontmatterDefinitionProvider.d.ts +28 -0
- package/dist/definitions/providers/FrontmatterDefinitionProvider.js +207 -0
- package/dist/definitions/providers/FrontmatterDefinitionProvider.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/TypeSystem.spec.ts +2 -2
- package/src/TypeSystem.ts +3 -11
- package/src/completions/CompletionsProvider.ts +120 -2
- package/src/completions/params/LiquidCompletionParams.ts +1 -2
- package/src/completions/providers/FrontmatterKeyCompletionProvider.spec.ts +196 -0
- package/src/completions/providers/FrontmatterKeyCompletionProvider.ts +182 -0
- package/src/completions/providers/index.ts +5 -0
- package/src/definitions/DefinitionProvider.ts +2 -0
- package/src/definitions/providers/FrontmatterDefinitionProvider.spec.ts +463 -0
- package/src/definitions/providers/FrontmatterDefinitionProvider.ts +258 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformos/platformos-language-server-common",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
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.
|
|
31
|
-
"@platformos/platformos-check-common": "0.0.
|
|
32
|
-
"@platformos/platformos-graph": "0.0.
|
|
30
|
+
"@platformos/liquid-html-parser": "^0.0.16",
|
|
31
|
+
"@platformos/platformos-check-common": "0.0.18",
|
|
32
|
+
"@platformos/platformos-graph": "0.0.18",
|
|
33
33
|
"@vscode/web-custom-data": "^0.4.6",
|
|
34
34
|
"graphql": "^16.12.0",
|
|
35
35
|
"graphql-language-service": "^5.2.2",
|
package/src/TypeSystem.spec.ts
CHANGED
|
@@ -882,8 +882,8 @@ query {
|
|
|
882
882
|
}
|
|
883
883
|
});
|
|
884
884
|
|
|
885
|
-
it('should support
|
|
886
|
-
const ast = toLiquidHtmlAST(`{% assign arr = [] %}{% assign arr
|
|
885
|
+
it('should support bare push form (assign arr << value)', async () => {
|
|
886
|
+
const ast = toLiquidHtmlAST(`{% assign arr = [] %}{% assign arr << "item" %}{{ arr }}`);
|
|
887
887
|
const variableOutput = ast.children[2];
|
|
888
888
|
assert(isLiquidVariableOutput(variableOutput));
|
|
889
889
|
const inferredType = await typeSystem.inferType(
|
package/src/TypeSystem.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AssignMarkup,
|
|
3
|
-
AssignPushRhs,
|
|
4
3
|
ComplexLiquidExpression,
|
|
5
4
|
FunctionMarkup,
|
|
6
5
|
LiquidDocParamNode,
|
|
@@ -382,14 +381,9 @@ async function buildSymbolsTable(
|
|
|
382
381
|
// {% assign x = {a: 1, b: "hello"} %}
|
|
383
382
|
// {% assign x["key"] = value %}
|
|
384
383
|
// {% assign arr << item %}
|
|
385
|
-
// {% assign arr = source << item %}
|
|
386
384
|
async AssignMarkup(node) {
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
const effectiveValue = isPushRhs
|
|
390
|
-
? (node.value as AssignPushRhs).pushValue
|
|
391
|
-
: (node.value as LiquidVariable);
|
|
392
|
-
const effectiveOperator = isPushRhs ? '<<' : node.operator;
|
|
385
|
+
const effectiveValue = node.value as LiquidVariable;
|
|
386
|
+
const effectiveOperator = node.operator;
|
|
393
387
|
const expression = effectiveValue.expression;
|
|
394
388
|
let valueShape: PropertyShape | undefined;
|
|
395
389
|
|
|
@@ -970,9 +964,7 @@ function inferType(
|
|
|
970
964
|
// The type of the assign markup is the type of the right hand side.
|
|
971
965
|
// {% assign x = y.property | filter1 | filter2 %}
|
|
972
966
|
case NodeTypes.AssignMarkup: {
|
|
973
|
-
|
|
974
|
-
thing.value.type === NodeTypes.AssignPushRhs ? thing.value.pushValue : thing.value;
|
|
975
|
-
return inferType(assignValue, symbolsTable, objectMap, filtersMap);
|
|
967
|
+
return inferType(thing.value, symbolsTable, objectMap, filtersMap);
|
|
976
968
|
}
|
|
977
969
|
|
|
978
970
|
// A variable lookup is expression[.lookup]*
|
|
@@ -3,8 +3,13 @@ import {
|
|
|
3
3
|
SourceCodeType,
|
|
4
4
|
PlatformOSDocset,
|
|
5
5
|
} from '@platformos/platformos-check-common';
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
type AbstractFileSystem,
|
|
8
|
+
type DocumentsLocator,
|
|
9
|
+
FileType,
|
|
10
|
+
} from '@platformos/platformos-common';
|
|
7
11
|
import { CompletionItem, CompletionParams } from 'vscode-languageserver';
|
|
12
|
+
import { URI, Utils } from 'vscode-uri';
|
|
8
13
|
import { TypeSystem } from '../TypeSystem';
|
|
9
14
|
import { DocumentManager } from '../documents';
|
|
10
15
|
import { FindAppRootURI } from '../internal-types';
|
|
@@ -25,6 +30,9 @@ import {
|
|
|
25
30
|
PartialCompletionProvider,
|
|
26
31
|
RenderPartialParameterCompletionProvider,
|
|
27
32
|
TranslationCompletionProvider,
|
|
33
|
+
FrontmatterKeyCompletionProvider,
|
|
34
|
+
GetLayoutNamesForURI,
|
|
35
|
+
GetAuthPolicyNamesForURI,
|
|
28
36
|
} from './providers';
|
|
29
37
|
import { GetPartialNamesForURI } from './providers/PartialCompletionProvider';
|
|
30
38
|
import { GraphQLFieldCompletionProvider } from './providers/GraphQLFieldCompletionProvider';
|
|
@@ -35,7 +43,7 @@ export interface CompletionProviderDependencies {
|
|
|
35
43
|
getTranslationsForURI?: GetTranslationsForURI;
|
|
36
44
|
getPartialNamesForURI?: GetPartialNamesForURI;
|
|
37
45
|
getDocDefinitionForURI?: GetDocDefinitionForURI;
|
|
38
|
-
/** File system for reading GraphQL files */
|
|
46
|
+
/** File system for reading GraphQL files and listing frontmatter-referenced files */
|
|
39
47
|
fs?: AbstractFileSystem;
|
|
40
48
|
/** Locator for finding documents by type */
|
|
41
49
|
documentsLocator?: DocumentsLocator;
|
|
@@ -44,6 +52,10 @@ export interface CompletionProviderDependencies {
|
|
|
44
52
|
log?: (message: string) => void;
|
|
45
53
|
/** Callback to notify when unable to infer properties for a variable */
|
|
46
54
|
notifyUnableToInferProperties?: (variableName: string) => void;
|
|
55
|
+
/** Override for listing available layout names (used in frontmatter value completions) */
|
|
56
|
+
getLayoutNamesForURI?: GetLayoutNamesForURI;
|
|
57
|
+
/** Override for listing available authorization policy names */
|
|
58
|
+
getAuthPolicyNamesForURI?: GetAuthPolicyNamesForURI;
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
export class CompletionsProvider {
|
|
@@ -63,6 +75,8 @@ export class CompletionsProvider {
|
|
|
63
75
|
documentsLocator,
|
|
64
76
|
findAppRootURI,
|
|
65
77
|
log = () => {},
|
|
78
|
+
getLayoutNamesForURI,
|
|
79
|
+
getAuthPolicyNamesForURI,
|
|
66
80
|
}: CompletionProviderDependencies) {
|
|
67
81
|
this.documentManager = documentManager;
|
|
68
82
|
this.platformosDocset = platformosDocset;
|
|
@@ -73,6 +87,27 @@ export class CompletionsProvider {
|
|
|
73
87
|
documentManager,
|
|
74
88
|
);
|
|
75
89
|
|
|
90
|
+
// Build layout/policy name callbacks from fs+findAppRootURI when not explicitly provided
|
|
91
|
+
let layoutNames: GetLayoutNamesForURI | undefined = getLayoutNamesForURI;
|
|
92
|
+
let authPolicyNames: GetAuthPolicyNamesForURI | undefined = getAuthPolicyNamesForURI;
|
|
93
|
+
|
|
94
|
+
if (fs && findAppRootURI) {
|
|
95
|
+
if (!layoutNames) {
|
|
96
|
+
layoutNames = async (uri: string) => {
|
|
97
|
+
const rootUri = await findAppRootURI(uri);
|
|
98
|
+
if (!rootUri) return [];
|
|
99
|
+
return listLayoutNames(fs, URI.parse(rootUri));
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (!authPolicyNames) {
|
|
103
|
+
authPolicyNames = async (uri: string) => {
|
|
104
|
+
const rootUri = await findAppRootURI(uri);
|
|
105
|
+
if (!rootUri) return [];
|
|
106
|
+
return listAuthPolicyNames(fs, URI.parse(rootUri));
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
this.providers = [
|
|
77
112
|
new HtmlTagCompletionProvider(),
|
|
78
113
|
new HtmlAttributeCompletionProvider(documentManager),
|
|
@@ -87,6 +122,7 @@ export class CompletionsProvider {
|
|
|
87
122
|
new FilterNamedParameterCompletionProvider(platformosDocset),
|
|
88
123
|
new LiquidDocTagCompletionProvider(),
|
|
89
124
|
new LiquidDocParamTypeCompletionProvider(platformosDocset),
|
|
125
|
+
new FrontmatterKeyCompletionProvider(layoutNames, authPolicyNames),
|
|
90
126
|
];
|
|
91
127
|
}
|
|
92
128
|
|
|
@@ -116,3 +152,85 @@ export class CompletionsProvider {
|
|
|
116
152
|
}
|
|
117
153
|
}
|
|
118
154
|
}
|
|
155
|
+
|
|
156
|
+
// ── File listing helpers ─────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** Recursively list .liquid files under a URI directory. Returns full URI strings. */
|
|
159
|
+
async function listLiquidFilesRecursively(fs: AbstractFileSystem, dirUri: URI): Promise<string[]> {
|
|
160
|
+
let entries: [string, FileType][];
|
|
161
|
+
try {
|
|
162
|
+
entries = await fs.readDirectory(dirUri.toString());
|
|
163
|
+
} catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const results: string[] = [];
|
|
168
|
+
for (const [entryUri, entryType] of entries) {
|
|
169
|
+
if (entryType === FileType.Directory) {
|
|
170
|
+
const sub = await listLiquidFilesRecursively(fs, URI.parse(entryUri));
|
|
171
|
+
results.push(...sub);
|
|
172
|
+
} else if (entryType === FileType.File && entryUri.endsWith('.liquid')) {
|
|
173
|
+
results.push(entryUri);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function listLayoutNames(fs: AbstractFileSystem, root: URI): Promise<string[]> {
|
|
180
|
+
const names: string[] = [];
|
|
181
|
+
|
|
182
|
+
// App layouts: app/views/layouts/**/*.liquid
|
|
183
|
+
const appLayoutsDir = Utils.joinPath(root, 'app', 'views', 'layouts');
|
|
184
|
+
const appBase = appLayoutsDir.toString() + '/';
|
|
185
|
+
for (const uri of await listLiquidFilesRecursively(fs, appLayoutsDir)) {
|
|
186
|
+
const rel = uri.startsWith(appBase) ? uri.slice(appBase.length) : uri;
|
|
187
|
+
names.push(rel.replace(/\.liquid$/, ''));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Module layouts from both modules/ and app/modules/ (overwrites).
|
|
191
|
+
// Both are reported as modules/{mod}/{rest} — the Set below deduplicates them.
|
|
192
|
+
for (const modulesRoot of ['modules', 'app/modules'] as const) {
|
|
193
|
+
let moduleEntries: [string, FileType][] = [];
|
|
194
|
+
try {
|
|
195
|
+
moduleEntries = await fs.readDirectory(Utils.joinPath(root, modulesRoot).toString());
|
|
196
|
+
} catch {
|
|
197
|
+
/* directory does not exist */
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (const [modDirUri, modType] of moduleEntries) {
|
|
201
|
+
if (modType !== FileType.Directory) continue;
|
|
202
|
+
const modName = modDirUri.replace(/\/$/, '').split('/').at(-1)!;
|
|
203
|
+
for (const visibility of ['public', 'private'] as const) {
|
|
204
|
+
const layoutsDir = Utils.joinPath(URI.parse(modDirUri), visibility, 'views', 'layouts');
|
|
205
|
+
const base = layoutsDir.toString() + '/';
|
|
206
|
+
for (const uri of await listLiquidFilesRecursively(fs, layoutsDir)) {
|
|
207
|
+
const rest = uri.startsWith(base) ? uri.slice(base.length) : uri;
|
|
208
|
+
names.push(`modules/${modName}/${rest.replace(/\.liquid$/, '')}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return [...new Set(names)].sort();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function listAuthPolicyNames(fs: AbstractFileSystem, root: URI): Promise<string[]> {
|
|
218
|
+
const dir = Utils.joinPath(root, 'app', 'authorization_policies');
|
|
219
|
+
let entries: [string, FileType][] = [];
|
|
220
|
+
try {
|
|
221
|
+
entries = await fs.readDirectory(dir.toString());
|
|
222
|
+
} catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return entries
|
|
227
|
+
.filter(([uri, type]) => type === FileType.File && uri.endsWith('.liquid'))
|
|
228
|
+
.map(([uri]) =>
|
|
229
|
+
uri
|
|
230
|
+
.replace(/\/$/, '')
|
|
231
|
+
.split('/')
|
|
232
|
+
.at(-1)!
|
|
233
|
+
.replace(/\.liquid$/, ''),
|
|
234
|
+
)
|
|
235
|
+
.sort();
|
|
236
|
+
}
|
|
@@ -447,8 +447,7 @@ function findCurrentNode(
|
|
|
447
447
|
case NodeTypes.ExportMarkup:
|
|
448
448
|
case NodeTypes.RedirectToMarkup:
|
|
449
449
|
case NodeTypes.IncludeFormMarkup:
|
|
450
|
-
case NodeTypes.SpamProtectionMarkup:
|
|
451
|
-
case NodeTypes.AssignPushRhs: {
|
|
450
|
+
case NodeTypes.SpamProtectionMarkup: {
|
|
452
451
|
break;
|
|
453
452
|
}
|
|
454
453
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, beforeEach, it, expect } from 'vitest';
|
|
2
|
+
import { CompletionsProvider } from '../CompletionsProvider';
|
|
3
|
+
import { DocumentManager } from '../../documents';
|
|
4
|
+
|
|
5
|
+
const mockDocset = {
|
|
6
|
+
graphQL: async () => null,
|
|
7
|
+
filters: async () => [],
|
|
8
|
+
objects: async () => [],
|
|
9
|
+
liquidDrops: async () => [],
|
|
10
|
+
tags: async () => [],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('Module: FrontmatterKeyCompletionProvider', async () => {
|
|
14
|
+
let provider: CompletionsProvider;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
provider = new CompletionsProvider({
|
|
18
|
+
documentManager: new DocumentManager(),
|
|
19
|
+
platformosDocset: mockDocset,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('completes a key from a prefix inside page frontmatter', async () => {
|
|
24
|
+
// "slu" prefix matches only "slug" in the Page schema
|
|
25
|
+
await expect(provider).to.complete(
|
|
26
|
+
{
|
|
27
|
+
source: `---\nslu█\n---\n{{ content }}`,
|
|
28
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
29
|
+
},
|
|
30
|
+
['slug'],
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('completes a key from a prefix inside form_configurations frontmatter', async () => {
|
|
35
|
+
// "nam" prefix matches only "name" in the FormConfiguration schema
|
|
36
|
+
await expect(provider).to.complete(
|
|
37
|
+
{
|
|
38
|
+
source: `---\nnam█\n---\n`,
|
|
39
|
+
relativePath: 'app/form_configurations/test.liquid',
|
|
40
|
+
},
|
|
41
|
+
['name'],
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not complete in value position for fields without enum values', async () => {
|
|
46
|
+
// "slug" has no enumValues — value position should return nothing
|
|
47
|
+
await expect(provider).to.complete(
|
|
48
|
+
{
|
|
49
|
+
source: `---\nslug: █\n---\n{{ content }}`,
|
|
50
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
51
|
+
},
|
|
52
|
+
[],
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('completes enum values for the method field', async () => {
|
|
57
|
+
await expect(provider).to.complete(
|
|
58
|
+
{
|
|
59
|
+
source: `---\nmethod: █\n---\n{{ content }}`,
|
|
60
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
61
|
+
},
|
|
62
|
+
expect.arrayContaining([
|
|
63
|
+
{ label: 'get', kind: 12 },
|
|
64
|
+
{ label: 'post', kind: 12 },
|
|
65
|
+
]),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('filters enum completions by prefix', async () => {
|
|
70
|
+
await expect(provider).to.complete(
|
|
71
|
+
{
|
|
72
|
+
source: `---\nmethod: po█\n---\n{{ content }}`,
|
|
73
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
74
|
+
},
|
|
75
|
+
['post'],
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('completes layout names when getLayoutNamesForURI is provided', async () => {
|
|
80
|
+
const providerWithLayouts = new CompletionsProvider({
|
|
81
|
+
documentManager: new DocumentManager(),
|
|
82
|
+
platformosDocset: mockDocset,
|
|
83
|
+
getLayoutNamesForURI: async () => ['application', 'auth', 'modules/community/base'],
|
|
84
|
+
});
|
|
85
|
+
await expect(providerWithLayouts).to.complete(
|
|
86
|
+
{
|
|
87
|
+
source: `---\nlayout: app█\n---\n{{ content }}`,
|
|
88
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
89
|
+
},
|
|
90
|
+
['application'],
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('includes app/modules overwrite layouts alongside module layouts in completions', async () => {
|
|
95
|
+
// When both app/modules/community/public/views/layouts/base.liquid (overwrite) and
|
|
96
|
+
// the original modules/community/public/views/layouts/base.liquid are present,
|
|
97
|
+
// both appear as 'modules/community/base' and Set deduplication yields a single entry.
|
|
98
|
+
const providerWithLayouts = new CompletionsProvider({
|
|
99
|
+
documentManager: new DocumentManager(),
|
|
100
|
+
platformosDocset: mockDocset,
|
|
101
|
+
getLayoutNamesForURI: async () => ['modules/community/base'],
|
|
102
|
+
});
|
|
103
|
+
await expect(providerWithLayouts).to.complete(
|
|
104
|
+
{
|
|
105
|
+
source: `---\nlayout: modules/█\n---\n{{ content }}`,
|
|
106
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
107
|
+
},
|
|
108
|
+
['modules/community/base'],
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('filters module layout names by modules/ prefix', async () => {
|
|
113
|
+
const providerWithLayouts = new CompletionsProvider({
|
|
114
|
+
documentManager: new DocumentManager(),
|
|
115
|
+
platformosDocset: mockDocset,
|
|
116
|
+
getLayoutNamesForURI: async () => ['application', 'auth', 'modules/community/base'],
|
|
117
|
+
});
|
|
118
|
+
await expect(providerWithLayouts).to.complete(
|
|
119
|
+
{
|
|
120
|
+
source: `---\nlayout: modules/█\n---\n{{ content }}`,
|
|
121
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
122
|
+
},
|
|
123
|
+
['modules/community/base'],
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('returns no layout completions when getLayoutNamesForURI is not configured', async () => {
|
|
128
|
+
await expect(provider).to.complete(
|
|
129
|
+
{
|
|
130
|
+
source: `---\nlayout: █\n---\n{{ content }}`,
|
|
131
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
132
|
+
},
|
|
133
|
+
[],
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('completes auth policy list items when getAuthPolicyNamesForURI is provided', async () => {
|
|
138
|
+
const providerWithPolicies = new CompletionsProvider({
|
|
139
|
+
documentManager: new DocumentManager(),
|
|
140
|
+
platformosDocset: mockDocset,
|
|
141
|
+
getAuthPolicyNamesForURI: async () => ['is_authenticated', 'is_admin'],
|
|
142
|
+
});
|
|
143
|
+
await expect(providerWithPolicies).to.complete(
|
|
144
|
+
{
|
|
145
|
+
source: `---\nauthorization_policies:\n - is_a█\n---\n{{ content }}`,
|
|
146
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
147
|
+
},
|
|
148
|
+
expect.arrayContaining([{ label: 'is_admin', kind: 12 }]),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('completes enum values for the request_type field on ApiCall', async () => {
|
|
153
|
+
await expect(provider).to.complete(
|
|
154
|
+
{
|
|
155
|
+
source: `---\nrequest_type: █\n---\n`,
|
|
156
|
+
relativePath: 'app/notifications/api_call_notifications/test.liquid',
|
|
157
|
+
},
|
|
158
|
+
expect.arrayContaining([
|
|
159
|
+
{ label: 'GET', kind: 12 },
|
|
160
|
+
{ label: 'POST', kind: 12 },
|
|
161
|
+
{ label: 'DELETE', kind: 12 },
|
|
162
|
+
]),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('does not complete outside the frontmatter', async () => {
|
|
167
|
+
await expect(provider).to.complete(
|
|
168
|
+
{
|
|
169
|
+
source: `---\nslug: /home\n---\n{{ █ }}`,
|
|
170
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
171
|
+
},
|
|
172
|
+
[],
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('does not complete for files with no known schema', async () => {
|
|
177
|
+
await expect(provider).to.complete(
|
|
178
|
+
{
|
|
179
|
+
source: `---\nslu█\n---\n{{ content }}`,
|
|
180
|
+
relativePath: 'some/random/path/file.liquid',
|
|
181
|
+
},
|
|
182
|
+
[],
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('excludes already-used keys from completions', async () => {
|
|
187
|
+
// slug is already present — "slu" prefix should return nothing
|
|
188
|
+
await expect(provider).to.complete(
|
|
189
|
+
{
|
|
190
|
+
source: `---\nslug: /home\nslu█\n---\n{{ content }}`,
|
|
191
|
+
relativePath: 'app/views/pages/test.html.liquid',
|
|
192
|
+
},
|
|
193
|
+
[],
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { NodeTypes, YAMLFrontmatter } from '@platformos/liquid-html-parser';
|
|
2
|
+
import { getFrontmatterSchema, getFileType } from '@platformos/platformos-common';
|
|
3
|
+
import { CompletionItem, CompletionItemKind } from 'vscode-languageserver';
|
|
4
|
+
import { LiquidCompletionParams } from '../params';
|
|
5
|
+
import { Provider } from './common';
|
|
6
|
+
|
|
7
|
+
export type GetLayoutNamesForURI = (uri: string) => Promise<string[]>;
|
|
8
|
+
export type GetAuthPolicyNamesForURI = (uri: string) => Promise<string[]>;
|
|
9
|
+
|
|
10
|
+
export class FrontmatterKeyCompletionProvider implements Provider {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly getLayoutNamesForURI?: GetLayoutNamesForURI,
|
|
13
|
+
private readonly getAuthPolicyNamesForURI?: GetAuthPolicyNamesForURI,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async completions(params: LiquidCompletionParams): Promise<CompletionItem[]> {
|
|
17
|
+
const { document } = params;
|
|
18
|
+
|
|
19
|
+
// Use the full document AST — the partial AST used for other completions is truncated
|
|
20
|
+
// at the cursor, so the frontmatter closing "---" is never present there.
|
|
21
|
+
const ast = document.ast;
|
|
22
|
+
if (ast instanceof Error || ast.type !== NodeTypes.Document) return [];
|
|
23
|
+
|
|
24
|
+
const frontmatterNode = ast.children.find(
|
|
25
|
+
(child): child is YAMLFrontmatter => child.type === NodeTypes.YAMLFrontmatter,
|
|
26
|
+
);
|
|
27
|
+
if (!frontmatterNode) return [];
|
|
28
|
+
|
|
29
|
+
const schema = getFrontmatterSchema(getFileType(params.textDocument.uri));
|
|
30
|
+
if (!schema) return [];
|
|
31
|
+
|
|
32
|
+
// Locate the YAML body within the source: skip the opening "---\n"
|
|
33
|
+
const source = document.textDocument.getText();
|
|
34
|
+
const bodyStart = source.indexOf('\n', frontmatterNode.position.start) + 1;
|
|
35
|
+
const bodyEnd = bodyStart + frontmatterNode.body.length;
|
|
36
|
+
|
|
37
|
+
const cursor = document.textDocument.offsetAt(params.position);
|
|
38
|
+
if (cursor < bodyStart || cursor > bodyEnd) return [];
|
|
39
|
+
|
|
40
|
+
const cursorInBody = cursor - bodyStart;
|
|
41
|
+
|
|
42
|
+
// Determine what context the cursor is in based on the current line text.
|
|
43
|
+
const bodyUpToCursor = frontmatterNode.body.slice(0, cursorInBody);
|
|
44
|
+
const lastNewline = bodyUpToCursor.lastIndexOf('\n');
|
|
45
|
+
const currentLineText = bodyUpToCursor.slice(lastNewline + 1);
|
|
46
|
+
|
|
47
|
+
// ── List-item completion ─────────────────────────────────────────────────
|
|
48
|
+
// Must be checked before colonIndex since list items have no colon.
|
|
49
|
+
const listItemMatch = currentLineText.match(/^(\s*)-\s*(.*)/);
|
|
50
|
+
if (listItemMatch) {
|
|
51
|
+
const partial = listItemMatch[2];
|
|
52
|
+
const parentKey = findParentKey(bodyUpToCursor);
|
|
53
|
+
return this.listItemCompletions(parentKey, partial, params.textDocument.uri);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const colonIndex = currentLineText.indexOf(':');
|
|
57
|
+
|
|
58
|
+
if (colonIndex === -1) {
|
|
59
|
+
// ── Key completion ────────────────────────────────────────────────────
|
|
60
|
+
return this.keyCompletions(frontmatterNode.body, currentLineText, schema);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Scalar value completion ───────────────────────────────────────────────
|
|
64
|
+
const key = currentLineText.slice(0, colonIndex).trim();
|
|
65
|
+
const afterColon = currentLineText.slice(colonIndex + 1);
|
|
66
|
+
const rawPartial = afterColon.trimStart();
|
|
67
|
+
// Strip enclosing quotes for prefix matching, but keep raw text for filtering
|
|
68
|
+
const partial = rawPartial.replace(/^['"]/, '').replace(/['"]$/, '');
|
|
69
|
+
|
|
70
|
+
return this.valueCompletions(key, partial, schema, params.textDocument.uri);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Key completions ─────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
private keyCompletions(
|
|
76
|
+
body: string,
|
|
77
|
+
currentLineText: string,
|
|
78
|
+
schema: ReturnType<typeof getFrontmatterSchema>,
|
|
79
|
+
): CompletionItem[] {
|
|
80
|
+
if (!schema) return [];
|
|
81
|
+
const partial = currentLineText.trimStart();
|
|
82
|
+
|
|
83
|
+
// Collect keys already present so we can omit them.
|
|
84
|
+
const usedKeys = new Set<string>();
|
|
85
|
+
const keyRegex = /^([a-zA-Z_][a-zA-Z0-9_]*):/gm;
|
|
86
|
+
let match: RegExpExecArray | null;
|
|
87
|
+
while ((match = keyRegex.exec(body)) !== null) {
|
|
88
|
+
usedKeys.add(match[1]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Object.entries(schema.fields)
|
|
92
|
+
.filter(([key]) => !usedKeys.has(key) && key.startsWith(partial))
|
|
93
|
+
.map(([key, fieldSchema]): CompletionItem => {
|
|
94
|
+
const typeStr = Array.isArray(fieldSchema.type)
|
|
95
|
+
? fieldSchema.type.join(' | ')
|
|
96
|
+
: fieldSchema.type;
|
|
97
|
+
const tags = [
|
|
98
|
+
fieldSchema.required ? 'required' : undefined,
|
|
99
|
+
fieldSchema.deprecated ? 'deprecated' : undefined,
|
|
100
|
+
].filter(Boolean);
|
|
101
|
+
const detail = tags.length > 0 ? `${typeStr} (${tags.join(', ')})` : typeStr;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
label: key,
|
|
105
|
+
kind: CompletionItemKind.Field,
|
|
106
|
+
detail,
|
|
107
|
+
documentation: fieldSchema.description
|
|
108
|
+
? { kind: 'markdown', value: fieldSchema.description }
|
|
109
|
+
: undefined,
|
|
110
|
+
insertText: key + ': ',
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Value completions for scalar fields ─────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
private async valueCompletions(
|
|
118
|
+
key: string,
|
|
119
|
+
partial: string,
|
|
120
|
+
schema: ReturnType<typeof getFrontmatterSchema>,
|
|
121
|
+
uri: string,
|
|
122
|
+
): Promise<CompletionItem[]> {
|
|
123
|
+
if (!schema) return [];
|
|
124
|
+
|
|
125
|
+
// Layout field — list available layout files
|
|
126
|
+
if (key === 'layout' || key === 'layout_name') {
|
|
127
|
+
const names = (await this.getLayoutNamesForURI?.(uri)) ?? [];
|
|
128
|
+
return names
|
|
129
|
+
.filter((n) => n.startsWith(partial))
|
|
130
|
+
.map((n) => ({ label: n, kind: CompletionItemKind.Value }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fields with enum values
|
|
134
|
+
const fieldSchema = schema.fields[key];
|
|
135
|
+
if (fieldSchema?.enumValues) {
|
|
136
|
+
return fieldSchema.enumValues
|
|
137
|
+
.map(String)
|
|
138
|
+
.filter((v) => v.startsWith(partial))
|
|
139
|
+
.map((v) => ({
|
|
140
|
+
label: v,
|
|
141
|
+
kind: CompletionItemKind.Value,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── List-item completions (authorization_policies, etc.) ─────────────────────
|
|
149
|
+
|
|
150
|
+
private async listItemCompletions(
|
|
151
|
+
parentKey: string | undefined,
|
|
152
|
+
partial: string,
|
|
153
|
+
uri: string,
|
|
154
|
+
): Promise<CompletionItem[]> {
|
|
155
|
+
if (parentKey === 'authorization_policies') {
|
|
156
|
+
const names = (await this.getAuthPolicyNamesForURI?.(uri)) ?? [];
|
|
157
|
+
return names
|
|
158
|
+
.filter((n) => n.startsWith(partial))
|
|
159
|
+
.map((n) => ({ label: n, kind: CompletionItemKind.Value }));
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Walk backwards through the YAML body up-to-cursor to find the key that
|
|
166
|
+
* owns the current list block (the first non-indented, non-list-item line). */
|
|
167
|
+
function findParentKey(bodyUpToCursor: string): string | undefined {
|
|
168
|
+
const lines = bodyUpToCursor.split('\n');
|
|
169
|
+
// Start from the second-to-last line (the current line is the last)
|
|
170
|
+
for (let i = lines.length - 2; i >= 0; i--) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
if (!line.trim()) continue; // skip blank lines
|
|
173
|
+
// List item line — keep walking up
|
|
174
|
+
if (/^\s+-/.test(line)) continue;
|
|
175
|
+
// Top-level key line
|
|
176
|
+
const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):/);
|
|
177
|
+
if (match) return match[1];
|
|
178
|
+
// Anything else — stop
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
@@ -14,4 +14,9 @@ export {
|
|
|
14
14
|
} from './RenderPartialParameterCompletionProvider';
|
|
15
15
|
export { LiquidDocTagCompletionProvider } from './LiquidDocTagCompletionProvider';
|
|
16
16
|
export { LiquidDocParamTypeCompletionProvider } from './LiquidDocParamTypeCompletionProvider';
|
|
17
|
+
export {
|
|
18
|
+
FrontmatterKeyCompletionProvider,
|
|
19
|
+
GetLayoutNamesForURI,
|
|
20
|
+
GetAuthPolicyNamesForURI,
|
|
21
|
+
} from './FrontmatterKeyCompletionProvider';
|
|
17
22
|
export { Provider } from './common/Provider';
|
|
@@ -5,6 +5,7 @@ import { DefinitionLink, DefinitionParams } from 'vscode-languageserver';
|
|
|
5
5
|
import { AugmentedJsonSourceCode, DocumentManager } from '../documents';
|
|
6
6
|
import { SearchPathsLoader } from '../utils/searchPaths';
|
|
7
7
|
import { BaseDefinitionProvider } from './BaseDefinitionProvider';
|
|
8
|
+
import { FrontmatterDefinitionProvider } from './providers/FrontmatterDefinitionProvider';
|
|
8
9
|
import { PageRouteDefinitionProvider } from './providers/PageRouteDefinitionProvider';
|
|
9
10
|
import { RenderPartialDefinitionProvider } from './providers/RenderPartialDefinitionProvider';
|
|
10
11
|
import { TranslationStringDefinitionProvider } from './providers/TranslationStringDefinitionProvider';
|
|
@@ -28,6 +29,7 @@ export class DefinitionProvider {
|
|
|
28
29
|
if (fs && findAppRootURI) {
|
|
29
30
|
this.pageRouteProvider = new PageRouteDefinitionProvider(documentManager, fs, findAppRootURI);
|
|
30
31
|
this.providers.push(this.pageRouteProvider);
|
|
32
|
+
this.providers.push(new FrontmatterDefinitionProvider(documentManager, fs, findAppRootURI));
|
|
31
33
|
|
|
32
34
|
if (documentsLocator && searchPathsCache) {
|
|
33
35
|
this.providers.push(
|