@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
@@ -0,0 +1,258 @@
1
+ import { NodeTypes, YAMLFrontmatter } from '@platformos/liquid-html-parser';
2
+ import {
3
+ FRONTMATTER_ASSOCIATION_DIRS,
4
+ getFileType,
5
+ type AbstractFileSystem,
6
+ PlatformOSFileType,
7
+ } from '@platformos/platformos-common';
8
+ import { SourceCodeType } from '@platformos/platformos-check-common';
9
+ import {
10
+ DefinitionParams,
11
+ DefinitionLink,
12
+ Range,
13
+ LocationLink,
14
+ } from 'vscode-languageserver-protocol';
15
+ import { URI, Utils } from 'vscode-uri';
16
+ import { LiquidHtmlNode } from '@platformos/liquid-html-parser';
17
+ import { DocumentManager } from '../../documents';
18
+ import { BaseDefinitionProvider } from '../BaseDefinitionProvider';
19
+
20
+ export class FrontmatterDefinitionProvider implements BaseDefinitionProvider {
21
+ constructor(
22
+ private documentManager: DocumentManager,
23
+ private fs: AbstractFileSystem,
24
+ private findAppRootURI: (uri: string) => Promise<string | null>,
25
+ ) {}
26
+
27
+ async definitions(
28
+ params: DefinitionParams,
29
+ _node: LiquidHtmlNode,
30
+ _ancestors: LiquidHtmlNode[],
31
+ ): Promise<DefinitionLink[]> {
32
+ const uri = params.textDocument.uri;
33
+ const sourceCode = this.documentManager.get(uri);
34
+ if (
35
+ !sourceCode ||
36
+ sourceCode.type !== SourceCodeType.LiquidHtml ||
37
+ sourceCode.ast instanceof Error
38
+ )
39
+ return [];
40
+
41
+ const ast = sourceCode.ast;
42
+ if (ast.type !== NodeTypes.Document) return [];
43
+
44
+ const frontmatterNode = ast.children.find(
45
+ (child): child is YAMLFrontmatter => child.type === NodeTypes.YAMLFrontmatter,
46
+ );
47
+ if (!frontmatterNode) return [];
48
+
49
+ const doc = sourceCode.textDocument;
50
+ const source = doc.getText();
51
+
52
+ const bodyStart = source.indexOf('\n', frontmatterNode.position.start) + 1;
53
+ const bodyEnd = bodyStart + frontmatterNode.body.length;
54
+ const cursor = doc.offsetAt(params.position);
55
+
56
+ if (cursor < bodyStart || cursor > bodyEnd) return [];
57
+
58
+ const cursorInBody = cursor - bodyStart;
59
+ const bodyUpToCursor = frontmatterNode.body.slice(0, cursorInBody);
60
+
61
+ // Determine the current line
62
+ const lastNewline = bodyUpToCursor.lastIndexOf('\n');
63
+ const currentLineText = bodyUpToCursor.slice(lastNewline + 1);
64
+
65
+ // Determine remaining text on current line
66
+ const bodyFromCursor = frontmatterNode.body.slice(cursorInBody);
67
+ const nextNewline = bodyFromCursor.indexOf('\n');
68
+ const restOfLine = nextNewline === -1 ? bodyFromCursor : bodyFromCursor.slice(0, nextNewline);
69
+
70
+ const fullCurrentLine = currentLineText + restOfLine;
71
+
72
+ // List item: line starts with optional whitespace + "- " (no colon, check first)
73
+ const listItemMatch = fullCurrentLine.match(/^(\s*)-\s*(.*)/);
74
+ if (listItemMatch) {
75
+ const parentKey = findParentKey(bodyUpToCursor);
76
+ const appDir = parentKey ? FRONTMATTER_ASSOCIATION_DIRS[parentKey] : undefined;
77
+ if (!appDir) return [];
78
+
79
+ const itemValue = listItemMatch[2].trim().replace(/^['"]/, '').replace(/['"]$/, '');
80
+ if (!itemValue || itemValue.includes('{{') || itemValue.includes('{%')) return [];
81
+
82
+ return this.resolveAssociationDefinition(
83
+ uri,
84
+ itemValue,
85
+ appDir,
86
+ lastNewline + 1 + bodyStart,
87
+ doc,
88
+ );
89
+ }
90
+
91
+ const colonIndex = fullCurrentLine.indexOf(':');
92
+ if (colonIndex === -1) return [];
93
+
94
+ const key = fullCurrentLine.slice(0, colonIndex).trim();
95
+
96
+ // Scalar value: cursor must be after the colon
97
+ if (cursor <= bodyStart + lastNewline + 1 + colonIndex) return [];
98
+
99
+ // `layout` / `layout_name` are valid on Page; `layout` / `layout_path` on Email.
100
+ const fileType = getFileType(uri);
101
+ const isLayoutKey =
102
+ (fileType === PlatformOSFileType.Page && (key === 'layout' || key === 'layout_name')) ||
103
+ (fileType === PlatformOSFileType.Email && (key === 'layout' || key === 'layout_path'));
104
+ if (!isLayoutKey) return [];
105
+
106
+ const afterColon = fullCurrentLine.slice(colonIndex + 1).trimStart();
107
+ const value = afterColon.replace(/^['"]/, '').replace(/['"]$/, '').trim();
108
+
109
+ if (!value || value.includes('{{') || value.includes('{%')) return [];
110
+
111
+ // Compute origin range: from after colon+space to end of value
112
+ const lineStart = bodyStart + lastNewline + 1;
113
+ const valueStartInLine =
114
+ colonIndex + 1 + (fullCurrentLine.slice(colonIndex + 1).length - afterColon.length);
115
+ const originStart = lineStart + valueStartInLine;
116
+ const originEnd = originStart + afterColon.length;
117
+
118
+ return this.resolveLayoutDefinition(uri, value, originStart, originEnd, doc);
119
+ }
120
+
121
+ private async resolveLayoutDefinition(
122
+ fileUri: string,
123
+ layoutName: string,
124
+ originStart: number,
125
+ originEnd: number,
126
+ doc: NonNullable<ReturnType<DocumentManager['get']>>['textDocument'],
127
+ ): Promise<DefinitionLink[]> {
128
+ const rootUri = await this.findAppRootURI(fileUri);
129
+ if (!rootUri) return [];
130
+ const root = URI.parse(rootUri);
131
+
132
+ let targetUri: string | undefined;
133
+
134
+ if (layoutName.startsWith('modules/')) {
135
+ const match = layoutName.match(/^modules\/([^/]+)\/(.+)$/);
136
+ if (!match) return [];
137
+ const [, mod, rest] = match;
138
+
139
+ // Check app overwrite first (app/modules/{mod}/{visibility}/views/layouts/{rest}.liquid),
140
+ // then fall back to the original module path (modules/{mod}/{visibility}/...).
141
+ // Both visibilities are checked for each root before moving to the next.
142
+ const roots: Array<(v: string) => URI> = [
143
+ (v) => Utils.joinPath(root, 'app', 'modules', mod, v, 'views', 'layouts', `${rest}.liquid`),
144
+ (v) => Utils.joinPath(root, 'modules', mod, v, 'views', 'layouts', `${rest}.liquid`),
145
+ ];
146
+ outer: for (const makeCandidate of roots) {
147
+ for (const visibility of ['public', 'private'] as const) {
148
+ const candidate = makeCandidate(visibility);
149
+ if (await this.fileExists(candidate.toString())) {
150
+ targetUri = candidate.toString();
151
+ break outer;
152
+ }
153
+ }
154
+ }
155
+ } else {
156
+ const candidate = Utils.joinPath(root, 'app', 'views', 'layouts', `${layoutName}.liquid`);
157
+ if (await this.fileExists(candidate.toString())) {
158
+ targetUri = candidate.toString();
159
+ }
160
+ }
161
+
162
+ if (!targetUri) return [];
163
+
164
+ const originRange = Range.create(doc.positionAt(originStart), doc.positionAt(originEnd));
165
+ const targetRange = Range.create(0, 0, 0, 0);
166
+ return [LocationLink.create(targetUri, targetRange, targetRange, originRange)];
167
+ }
168
+
169
+ /**
170
+ * Resolves a frontmatter list-item value to a definition link.
171
+ *
172
+ * App-level items (e.g. `require_login`) resolve to:
173
+ * app/{appDir}/{name}.liquid
174
+ *
175
+ * Module-prefixed items (e.g. `modules/community/require_login`) resolve to the first
176
+ * existing path in priority order:
177
+ * app/modules/{mod}/public/{appDir}/{name}.liquid (app overwrite, public)
178
+ * app/modules/{mod}/private/{appDir}/{name}.liquid (app overwrite, private)
179
+ * modules/{mod}/public/{appDir}/{name}.liquid
180
+ * modules/{mod}/private/{appDir}/{name}.liquid
181
+ */
182
+ private async resolveAssociationDefinition(
183
+ fileUri: string,
184
+ itemName: string,
185
+ appDir: string,
186
+ lineAbsStart: number,
187
+ doc: NonNullable<ReturnType<DocumentManager['get']>>['textDocument'],
188
+ ): Promise<DefinitionLink[]> {
189
+ const rootUri = await this.findAppRootURI(fileUri);
190
+ if (!rootUri) return [];
191
+ const root = URI.parse(rootUri);
192
+
193
+ let targetUri: string | undefined;
194
+
195
+ if (itemName.startsWith('modules/')) {
196
+ const match = itemName.match(/^modules\/([^/]+)\/(.+)$/);
197
+ if (!match) return [];
198
+ const [, mod, name] = match;
199
+ const candidates = [
200
+ Utils.joinPath(root, 'app', 'modules', mod, 'public', appDir, `${name}.liquid`),
201
+ Utils.joinPath(root, 'app', 'modules', mod, 'private', appDir, `${name}.liquid`),
202
+ Utils.joinPath(root, 'modules', mod, 'public', appDir, `${name}.liquid`),
203
+ Utils.joinPath(root, 'modules', mod, 'private', appDir, `${name}.liquid`),
204
+ ];
205
+ for (const candidate of candidates) {
206
+ if (await this.fileExists(candidate.toString())) {
207
+ targetUri = candidate.toString();
208
+ break;
209
+ }
210
+ }
211
+ } else {
212
+ const candidate = Utils.joinPath(root, 'app', appDir, `${itemName}.liquid`);
213
+ if (await this.fileExists(candidate.toString())) {
214
+ targetUri = candidate.toString();
215
+ }
216
+ }
217
+
218
+ if (!targetUri) return [];
219
+
220
+ // Compute origin range: from after the "- " to end of the item value on this line
221
+ const body = doc.getText();
222
+ const lineText = body.slice(lineAbsStart, lineAbsStart + 200).split('\n')[0] ?? '';
223
+ const dashIdx = lineText.indexOf('-');
224
+ if (dashIdx === -1) return [];
225
+ const valueOffset =
226
+ lineAbsStart +
227
+ dashIdx +
228
+ 1 +
229
+ (lineText.slice(dashIdx + 1).length - lineText.slice(dashIdx + 1).trimStart().length);
230
+ const valueEnd = lineAbsStart + lineText.length;
231
+
232
+ const originRange = Range.create(doc.positionAt(valueOffset), doc.positionAt(valueEnd));
233
+ const targetRange = Range.create(0, 0, 0, 0);
234
+ return [LocationLink.create(targetUri, targetRange, targetRange, originRange)];
235
+ }
236
+
237
+ private async fileExists(uri: string): Promise<boolean> {
238
+ try {
239
+ await this.fs.stat(uri);
240
+ return true;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+ }
246
+
247
+ function findParentKey(bodyUpToCursor: string): string | undefined {
248
+ const lines = bodyUpToCursor.split('\n');
249
+ for (let i = lines.length - 2; i >= 0; i--) {
250
+ const line = lines[i];
251
+ if (!line.trim()) continue;
252
+ if (/^\s+-/.test(line)) continue;
253
+ const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):/);
254
+ if (match) return match[1];
255
+ break;
256
+ }
257
+ return undefined;
258
+ }