@prisma-next/language-server 0.14.0-dev.37 → 0.14.0-dev.39

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.
@@ -0,0 +1,432 @@
1
+ import {
2
+ AttributeArgListAst,
3
+ any,
4
+ type BracedBlock,
5
+ CompositeTypeDeclarationAst,
6
+ type DocumentAst,
7
+ FieldAttributeAst,
8
+ FieldDeclarationAst,
9
+ GenericBlockDeclarationAst,
10
+ KeyValuePairAst,
11
+ ModelAttributeAst,
12
+ ModelDeclarationAst,
13
+ NamespaceDeclarationAst,
14
+ type Position,
15
+ type QualifiedNameAst,
16
+ type SourceFile,
17
+ type SyntaxNode,
18
+ type SyntaxToken,
19
+ skipTriviaToken,
20
+ type TokenAtOffset,
21
+ TypesBlockAst,
22
+ } from '@prisma-next/psl-parser/syntax';
23
+
24
+ export interface ClassifyPslCompletionContextInput {
25
+ readonly document: DocumentAst;
26
+ readonly sourceFile: SourceFile;
27
+ readonly position: Position;
28
+ }
29
+
30
+ export interface ModelTypeCompletionContext {
31
+ readonly kind: 'modelType';
32
+ readonly offset: number;
33
+ readonly fieldName: string;
34
+ readonly replacementStartOffset: number;
35
+ }
36
+
37
+ export interface SpaceMemberCompletionContext {
38
+ readonly kind: 'spaceMember';
39
+ readonly offset: number;
40
+ readonly fieldName: string;
41
+ readonly replacementStartOffset: number;
42
+ readonly space: string;
43
+ }
44
+
45
+ export interface NamespaceMemberCompletionContext {
46
+ readonly kind: 'namespaceMember';
47
+ readonly offset: number;
48
+ readonly fieldName: string;
49
+ readonly replacementStartOffset: number;
50
+ readonly namespace: string;
51
+ readonly space?: string;
52
+ }
53
+
54
+ export interface GenericBlockKeyCompletionContext {
55
+ readonly kind: 'genericBlockKey';
56
+ readonly offset: number;
57
+ readonly blockKeyword: string;
58
+ readonly replacementStartOffset: number;
59
+ readonly block: GenericBlockDeclarationAst;
60
+ }
61
+
62
+ export interface GenericBlockValueCompletionContext {
63
+ readonly kind: 'genericBlockValue';
64
+ readonly offset: number;
65
+ readonly blockKeyword: string;
66
+ readonly replacementStartOffset: number;
67
+ }
68
+
69
+ export type DeclarationKeywordCompletionScope = 'document' | 'namespace';
70
+
71
+ export interface DeclarationKeywordCompletionContext {
72
+ readonly kind: 'declarationKeyword';
73
+ readonly offset: number;
74
+ readonly scope: DeclarationKeywordCompletionScope;
75
+ readonly replacementStartOffset: number;
76
+ }
77
+
78
+ export interface UnsupportedPslCompletionContext {
79
+ readonly kind: 'unsupported';
80
+ }
81
+
82
+ export type PslCompletionContext =
83
+ | DeclarationKeywordCompletionContext
84
+ | GenericBlockKeyCompletionContext
85
+ | GenericBlockValueCompletionContext
86
+ | ModelTypeCompletionContext
87
+ | NamespaceMemberCompletionContext
88
+ | SpaceMemberCompletionContext
89
+ | UnsupportedPslCompletionContext;
90
+
91
+ const UNSUPPORTED: UnsupportedPslCompletionContext = { kind: 'unsupported' };
92
+
93
+ export function classifyPslCompletionContext(
94
+ input: ClassifyPslCompletionContextInput,
95
+ ): PslCompletionContext {
96
+ const root = input.document.syntax;
97
+ const offset = input.sourceFile.offsetAt(input.position);
98
+ const at = root.tokenAtOffset(offset);
99
+
100
+ // Completion is never offered when the cursor sits inside a comment.
101
+ if (at.leftBiased()?.kind === 'Comment') {
102
+ return UNSUPPORTED;
103
+ }
104
+
105
+ // The edit replaces the identifier under the cursor, or is empty when the
106
+ // cursor sits in trivia.
107
+ const edit = cursorIdentifier(at, offset);
108
+
109
+ // Anchor on the significant token preceding the cursor and navigate outward
110
+ // via `token.parent` rather than scanning the whole tree.
111
+ const preceding = precedingToken(at, edit);
112
+ const precedingNode = preceding?.parent;
113
+ const replacementStartOffset = edit?.offset ?? offset;
114
+
115
+ const declarationKeywordContext = classifyDeclarationKeyword({
116
+ node: precedingNode,
117
+ offset,
118
+ replacementStartOffset,
119
+ });
120
+ if (declarationKeywordContext !== undefined) {
121
+ return declarationKeywordContext;
122
+ }
123
+
124
+ const genericBlockContext = classifyGenericBlockParameter({
125
+ offset,
126
+ at,
127
+ precedingToken: preceding,
128
+ replacementStartOffset,
129
+ });
130
+ if (genericBlockContext !== undefined) {
131
+ return genericBlockContext;
132
+ }
133
+
134
+ const field = fieldForTypeSlot(precedingNode);
135
+ if (field === undefined) {
136
+ return UNSUPPORTED;
137
+ }
138
+ if (
139
+ field.syntax.findAncestor(any(ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast)) ===
140
+ undefined
141
+ ) {
142
+ return UNSUPPORTED;
143
+ }
144
+
145
+ return classifyModelFieldType({
146
+ field,
147
+ offset,
148
+ replacementStartOffset,
149
+ precedingToken: preceding,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Locates the field whose type position the cursor occupies. The preceding token
155
+ * climbs to the field whether the cursor sits inside a present type (the type
156
+ * identifier's predecessor still belongs to the field) or in the empty type slot
157
+ * of a typeless field (whose trailing trivia lives in the enclosing block, so
158
+ * the nearest significant token to the left is the field's own name).
159
+ */
160
+ function fieldForTypeSlot(precedingNode: SyntaxNode | undefined): FieldDeclarationAst | undefined {
161
+ return precedingNode?.findAncestor(FieldDeclarationAst.cast);
162
+ }
163
+
164
+ function classifyModelFieldType(input: {
165
+ readonly field: FieldDeclarationAst;
166
+ readonly offset: number;
167
+ readonly replacementStartOffset: number;
168
+ readonly precedingToken: SyntaxToken | undefined;
169
+ }): PslCompletionContext {
170
+ const fieldName = input.field.name();
171
+ if (fieldName === undefined) {
172
+ return UNSUPPORTED;
173
+ }
174
+ const fieldNameText = fieldName.name();
175
+ if (fieldNameText === undefined) {
176
+ return UNSUPPORTED;
177
+ }
178
+
179
+ if (fieldName.syntax.isInside(input.offset)) {
180
+ return UNSUPPORTED;
181
+ }
182
+
183
+ const typeAnnotation = input.field.typeAnnotation();
184
+ if (typeAnnotation === undefined) {
185
+ if (
186
+ input.precedingToken !== undefined &&
187
+ fieldName.syntax.isInside(input.precedingToken.offset)
188
+ ) {
189
+ return {
190
+ kind: 'modelType',
191
+ offset: input.offset,
192
+ fieldName: fieldNameText,
193
+ replacementStartOffset: input.offset,
194
+ };
195
+ }
196
+ return UNSUPPORTED;
197
+ }
198
+
199
+ if (typeAnnotation.syntax.isOutside(input.offset)) {
200
+ return UNSUPPORTED;
201
+ }
202
+
203
+ const constructorArgList = typeAnnotation.argList();
204
+ if (constructorArgList?.syntax.isInside(input.offset)) {
205
+ return UNSUPPORTED;
206
+ }
207
+
208
+ const name = typeAnnotation.name();
209
+ if (name === undefined) {
210
+ return UNSUPPORTED;
211
+ }
212
+ if (name.syntax.isOutside(input.offset)) {
213
+ return UNSUPPORTED;
214
+ }
215
+ if (name.isOverQualified()) {
216
+ return UNSUPPORTED;
217
+ }
218
+
219
+ return classifyTypePosition(name, input.offset, fieldNameText, input.replacementStartOffset);
220
+ }
221
+
222
+ /**
223
+ * Builds the type-completion context for a qualified name. Roles are read
224
+ * straight off the separator-positional accessors: a populated namespace
225
+ * segment is a `.`-qualified name, a populated space segment is a `:`-qualified
226
+ * name, and the absence of both is a bare model type.
227
+ *
228
+ * Behaviour change: a `:`-qualified name with no `.` (e.g. `supabase:`,
229
+ * `supabase:U`) is a `spaceMember` position rather than falling through to bare
230
+ * model-type completions. A malformed leading-separator name (`:User`, `.User`)
231
+ * carries no populated segment and resolves to `modelType` rather than
232
+ * `unsupported`.
233
+ */
234
+ function classifyTypePosition(
235
+ name: QualifiedNameAst,
236
+ offset: number,
237
+ fieldName: string,
238
+ replacementStartOffset: number,
239
+ ): ModelTypeCompletionContext | SpaceMemberCompletionContext | NamespaceMemberCompletionContext {
240
+ const namespace = name.namespace()?.name();
241
+ if (namespace !== undefined && namespace.length > 0) {
242
+ const namespaceSpace = name.space()?.name();
243
+ return {
244
+ kind: 'namespaceMember',
245
+ offset,
246
+ fieldName,
247
+ replacementStartOffset,
248
+ namespace,
249
+ ...(namespaceSpace !== undefined && namespaceSpace.length > 0
250
+ ? { space: namespaceSpace }
251
+ : {}),
252
+ };
253
+ }
254
+ const space = name.space()?.name();
255
+ if (space !== undefined && space.length > 0) {
256
+ return { kind: 'spaceMember', offset, fieldName, replacementStartOffset, space };
257
+ }
258
+ return { kind: 'modelType', offset, fieldName, replacementStartOffset };
259
+ }
260
+
261
+ const declarationCast = any(
262
+ ModelDeclarationAst.cast,
263
+ CompositeTypeDeclarationAst.cast,
264
+ TypesBlockAst.cast,
265
+ GenericBlockDeclarationAst.cast,
266
+ NamespaceDeclarationAst.cast,
267
+ );
268
+
269
+ type DeclarationAst = NonNullable<ReturnType<typeof declarationCast>>;
270
+
271
+ function classifyDeclarationKeyword(input: {
272
+ readonly node: SyntaxNode | undefined;
273
+ readonly offset: number;
274
+ readonly replacementStartOffset: number;
275
+ }): DeclarationKeywordCompletionContext | undefined {
276
+ const precedingDeclaration = input.node?.findAncestor(declarationCast);
277
+ const namespace = input.node?.findAncestor(NamespaceDeclarationAst.cast);
278
+ const inNamespaceBody = blockBodyContainsOffset(namespace, input.offset);
279
+
280
+ if (
281
+ precedingDeclaration !== undefined &&
282
+ !canCompleteDeclaration(precedingDeclaration, input.offset, inNamespaceBody)
283
+ ) {
284
+ return undefined;
285
+ }
286
+
287
+ return {
288
+ kind: 'declarationKeyword',
289
+ offset: input.offset,
290
+ scope: inNamespaceBody ? 'namespace' : 'document',
291
+ replacementStartOffset: input.replacementStartOffset,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Whether a new declaration can begin at the cursor, given the nearest enclosing
297
+ * declaration. Allowed when that declaration is still nascent (only its keyword
298
+ * typed, no name or body yet), when it is a namespace whose body holds further
299
+ * declarations, or when the cursor sits past its closing `}`.
300
+ */
301
+ function canCompleteDeclaration(
302
+ precedingDeclaration: DeclarationAst,
303
+ offset: number,
304
+ inNamespaceBody: boolean,
305
+ ): boolean {
306
+ const keywordOnly =
307
+ precedingDeclaration.lbrace() === undefined &&
308
+ (precedingDeclaration instanceof TypesBlockAst || precedingDeclaration.name() === undefined);
309
+ if (keywordOnly) {
310
+ return true;
311
+ }
312
+ if (precedingDeclaration instanceof NamespaceDeclarationAst && inNamespaceBody) {
313
+ return true;
314
+ }
315
+ const rbrace = precedingDeclaration.rbrace();
316
+ return rbrace !== undefined && offset >= rbrace.endOffset;
317
+ }
318
+
319
+ function blockBodyContainsOffset(block: BracedBlock | undefined, offset: number): boolean {
320
+ if (block === undefined) {
321
+ return false;
322
+ }
323
+ const lbrace = block.lbrace();
324
+ if (lbrace === undefined) {
325
+ return false;
326
+ }
327
+ const bodyStart = lbrace.endOffset;
328
+ const bodyEnd = block.rbrace()?.offset ?? block.syntax.endOffset;
329
+ return offset >= bodyStart && offset <= bodyEnd;
330
+ }
331
+
332
+ function classifyGenericBlockParameter(input: {
333
+ readonly offset: number;
334
+ readonly at: TokenAtOffset;
335
+ readonly precedingToken: SyntaxToken | undefined;
336
+ readonly replacementStartOffset: number;
337
+ }): PslCompletionContext | undefined {
338
+ // Whether the cursor sits in a key, value, or attribute slot is a structural
339
+ // question, so it anchors on the cursor's own node — including any in-progress
340
+ // identifier — rather than the edit-skipped `precedingToken` used for gaps.
341
+ const node = input.at.leftBiased()?.parent;
342
+ const block = node?.findAncestor(GenericBlockDeclarationAst.cast);
343
+ if (block === undefined) {
344
+ return undefined;
345
+ }
346
+
347
+ if (hasUnsupportedAncestor(node)) {
348
+ return UNSUPPORTED;
349
+ }
350
+
351
+ if (!blockBodyContainsOffset(block, input.offset)) {
352
+ return UNSUPPORTED;
353
+ }
354
+
355
+ const field = node?.findAncestor(FieldDeclarationAst.cast);
356
+ if (field?.syntax.isInside(input.offset)) {
357
+ return UNSUPPORTED;
358
+ }
359
+
360
+ const keyword = block.keyword()?.text;
361
+ if (keyword === undefined || keyword.length === 0) {
362
+ return UNSUPPORTED;
363
+ }
364
+
365
+ // Value position: the cursor follows a `=`. The position is now classified
366
+ // distinctly from keys; populating value candidates is the provider's concern.
367
+ if (input.precedingToken?.kind === 'Equals') {
368
+ return {
369
+ kind: 'genericBlockValue',
370
+ offset: input.offset,
371
+ blockKeyword: keyword,
372
+ replacementStartOffset: input.replacementStartOffset,
373
+ };
374
+ }
375
+
376
+ const activePair = activeKeyValuePair(node, input.offset);
377
+ if (activePair !== undefined && isAfterEquals(activePair, input.offset)) {
378
+ return UNSUPPORTED;
379
+ }
380
+
381
+ return {
382
+ kind: 'genericBlockKey',
383
+ offset: input.offset,
384
+ blockKeyword: keyword,
385
+ replacementStartOffset: input.replacementStartOffset,
386
+ block,
387
+ };
388
+ }
389
+
390
+ function activeKeyValuePair(
391
+ node: SyntaxNode | undefined,
392
+ offset: number,
393
+ ): KeyValuePairAst | undefined {
394
+ const pair = node?.findAncestor(KeyValuePairAst.cast);
395
+ if (pair === undefined || pair.syntax.isOutside(offset)) {
396
+ return undefined;
397
+ }
398
+ return pair;
399
+ }
400
+
401
+ function isAfterEquals(pair: KeyValuePairAst, offset: number): boolean {
402
+ const equals = pair.equals();
403
+ return equals !== undefined && offset > equals.offset;
404
+ }
405
+
406
+ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean {
407
+ return (
408
+ node?.findAncestor(
409
+ any(AttributeArgListAst.cast, FieldAttributeAst.cast, ModelAttributeAst.cast),
410
+ ) !== undefined
411
+ );
412
+ }
413
+
414
+ /** The significant token preceding the cursor — the in-progress edit identifier
415
+ * is skipped, so the result is the token the classifier anchors on. */
416
+ function precedingToken(at: TokenAtOffset, edit: SyntaxToken | undefined): SyntaxToken | undefined {
417
+ const start = edit !== undefined ? edit.prevToken : at.leftBiased();
418
+ return start === undefined ? undefined : skipTriviaToken(start, 'prev');
419
+ }
420
+
421
+ /** The identifier token the cursor is editing, if any. */
422
+ function cursorIdentifier(at: TokenAtOffset, offset: number): SyntaxToken | undefined {
423
+ const right = at.rightBiased();
424
+ if (right?.kind === 'Ident' && offset < right.endOffset) {
425
+ return right;
426
+ }
427
+ const left = at.leftBiased();
428
+ if (left?.kind === 'Ident' && left.endOffset === offset) {
429
+ return left;
430
+ }
431
+ return undefined;
432
+ }