@platformos/platformos-language-server-common 0.0.17 → 0.0.19

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 (32) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/TypeSystem.js +3 -9
  3. package/dist/TypeSystem.js.map +1 -1
  4. package/dist/codeActions/CodeActionsProvider.d.ts +1 -1
  5. package/dist/completions/CompletionsProvider.d.ts +8 -3
  6. package/dist/completions/CompletionsProvider.js +100 -1
  7. package/dist/completions/CompletionsProvider.js.map +1 -1
  8. package/dist/completions/params/LiquidCompletionParams.js +1 -2
  9. package/dist/completions/params/LiquidCompletionParams.js.map +1 -1
  10. package/dist/completions/providers/FrontmatterKeyCompletionProvider.d.ts +14 -0
  11. package/dist/completions/providers/FrontmatterKeyCompletionProvider.js +149 -0
  12. package/dist/completions/providers/FrontmatterKeyCompletionProvider.js.map +1 -0
  13. package/dist/completions/providers/index.d.ts +1 -0
  14. package/dist/completions/providers/index.js +3 -1
  15. package/dist/completions/providers/index.js.map +1 -1
  16. package/dist/definitions/DefinitionProvider.js +2 -0
  17. package/dist/definitions/DefinitionProvider.js.map +1 -1
  18. package/dist/definitions/providers/FrontmatterDefinitionProvider.d.ts +28 -0
  19. package/dist/definitions/providers/FrontmatterDefinitionProvider.js +207 -0
  20. package/dist/definitions/providers/FrontmatterDefinitionProvider.js.map +1 -0
  21. package/dist/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +4 -4
  23. package/src/TypeSystem.spec.ts +2 -2
  24. package/src/TypeSystem.ts +3 -11
  25. package/src/completions/CompletionsProvider.ts +120 -2
  26. package/src/completions/params/LiquidCompletionParams.ts +1 -2
  27. package/src/completions/providers/FrontmatterKeyCompletionProvider.spec.ts +196 -0
  28. package/src/completions/providers/FrontmatterKeyCompletionProvider.ts +182 -0
  29. package/src/completions/providers/index.ts +5 -0
  30. package/src/definitions/DefinitionProvider.ts +2 -0
  31. package/src/definitions/providers/FrontmatterDefinitionProvider.spec.ts +463 -0
  32. 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.17",
3
+ "version": "0.0.19",
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.15",
31
- "@platformos/platformos-check-common": "0.0.17",
32
- "@platformos/platformos-graph": "0.0.17",
30
+ "@platformos/liquid-html-parser": "^0.0.17",
31
+ "@platformos/platformos-check-common": "0.0.19",
32
+ "@platformos/platformos-graph": "0.0.19",
33
33
  "@vscode/web-custom-data": "^0.4.6",
34
34
  "graphql": "^16.12.0",
35
35
  "graphql-language-service": "^5.2.2",
@@ -882,8 +882,8 @@ query {
882
882
  }
883
883
  });
884
884
 
885
- it('should support explicit push form (assign a = source << value)', async () => {
886
- const ast = toLiquidHtmlAST(`{% assign arr = [] %}{% assign arr = arr << "item" %}{{ 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
- // For explicit push form (assign a = source << value), treat as array append
388
- const isPushRhs = node.value.type === NodeTypes.AssignPushRhs;
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
- const assignValue =
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 type { AbstractFileSystem, DocumentsLocator } from '@platformos/platformos-common';
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(