@cratis/screenplay-language 0.1.0

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 (147) hide show
  1. package/README.md +52 -0
  2. package/completion-items.ts +128 -0
  3. package/completion-planner.ts +111 -0
  4. package/completions.ts +112 -0
  5. package/diagnostics.ts +52 -0
  6. package/dist/completion-items.d.ts +23 -0
  7. package/dist/completion-items.d.ts.map +1 -0
  8. package/dist/completion-items.js +105 -0
  9. package/dist/completion-items.js.map +1 -0
  10. package/dist/completion-planner.d.ts +25 -0
  11. package/dist/completion-planner.d.ts.map +1 -0
  12. package/dist/completion-planner.js +90 -0
  13. package/dist/completion-planner.js.map +1 -0
  14. package/dist/completions.d.ts +4 -0
  15. package/dist/completions.d.ts.map +1 -0
  16. package/dist/completions.js +78 -0
  17. package/dist/completions.js.map +1 -0
  18. package/dist/diagnostics.d.ts +5 -0
  19. package/dist/diagnostics.d.ts.map +1 -0
  20. package/dist/diagnostics.js +47 -0
  21. package/dist/diagnostics.js.map +1 -0
  22. package/dist/document-context.d.ts +5 -0
  23. package/dist/document-context.d.ts.map +1 -0
  24. package/dist/document-context.js +45 -0
  25. package/dist/document-context.js.map +1 -0
  26. package/dist/hover-content.d.ts +2 -0
  27. package/dist/hover-content.d.ts.map +1 -0
  28. package/dist/hover-content.js +64 -0
  29. package/dist/hover-content.js.map +1 -0
  30. package/dist/hover.d.ts +3 -0
  31. package/dist/hover.d.ts.map +1 -0
  32. package/dist/hover.js +15 -0
  33. package/dist/hover.js.map +1 -0
  34. package/dist/index.d.ts +23 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +54 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/keyword-docs.d.ts +4 -0
  39. package/dist/keyword-docs.d.ts.map +1 -0
  40. package/dist/keyword-docs.js +100 -0
  41. package/dist/keyword-docs.js.map +1 -0
  42. package/dist/language.d.ts +12 -0
  43. package/dist/language.d.ts.map +1 -0
  44. package/dist/language.js +117 -0
  45. package/dist/language.js.map +1 -0
  46. package/dist/sub-language-registry.d.ts +20 -0
  47. package/dist/sub-language-registry.d.ts.map +1 -0
  48. package/dist/sub-language-registry.js +18 -0
  49. package/dist/sub-language-registry.js.map +1 -0
  50. package/dist/sub-languages/capture/CompletionProvider.d.ts +32 -0
  51. package/dist/sub-languages/capture/CompletionProvider.d.ts.map +1 -0
  52. package/dist/sub-languages/capture/CompletionProvider.js +332 -0
  53. package/dist/sub-languages/capture/CompletionProvider.js.map +1 -0
  54. package/dist/sub-languages/capture/HoverProvider.d.ts +8 -0
  55. package/dist/sub-languages/capture/HoverProvider.d.ts.map +1 -0
  56. package/dist/sub-languages/capture/HoverProvider.js +87 -0
  57. package/dist/sub-languages/capture/HoverProvider.js.map +1 -0
  58. package/dist/sub-languages/capture/Validator.d.ts +6 -0
  59. package/dist/sub-languages/capture/Validator.d.ts.map +1 -0
  60. package/dist/sub-languages/capture/Validator.js +72 -0
  61. package/dist/sub-languages/capture/Validator.js.map +1 -0
  62. package/dist/sub-languages/capture/index.d.ts +6 -0
  63. package/dist/sub-languages/capture/index.d.ts.map +1 -0
  64. package/dist/sub-languages/capture/index.js +58 -0
  65. package/dist/sub-languages/capture/index.js.map +1 -0
  66. package/dist/sub-languages/capture/language.d.ts +6 -0
  67. package/dist/sub-languages/capture/language.d.ts.map +1 -0
  68. package/dist/sub-languages/capture/language.js +160 -0
  69. package/dist/sub-languages/capture/language.js.map +1 -0
  70. package/dist/sub-languages/cdl.d.ts +3 -0
  71. package/dist/sub-languages/cdl.d.ts.map +1 -0
  72. package/dist/sub-languages/cdl.js +74 -0
  73. package/dist/sub-languages/cdl.js.map +1 -0
  74. package/dist/sub-languages/pdl.d.ts +3 -0
  75. package/dist/sub-languages/pdl.d.ts.map +1 -0
  76. package/dist/sub-languages/pdl.js +67 -0
  77. package/dist/sub-languages/pdl.js.map +1 -0
  78. package/dist/sub-languages/projection/CodeActionProvider.d.ts +18 -0
  79. package/dist/sub-languages/projection/CodeActionProvider.d.ts.map +1 -0
  80. package/dist/sub-languages/projection/CodeActionProvider.js +93 -0
  81. package/dist/sub-languages/projection/CodeActionProvider.js.map +1 -0
  82. package/dist/sub-languages/projection/CompletionProvider.d.ts +41 -0
  83. package/dist/sub-languages/projection/CompletionProvider.d.ts.map +1 -0
  84. package/dist/sub-languages/projection/CompletionProvider.js +591 -0
  85. package/dist/sub-languages/projection/CompletionProvider.js.map +1 -0
  86. package/dist/sub-languages/projection/HoverProvider.d.ts +24 -0
  87. package/dist/sub-languages/projection/HoverProvider.d.ts.map +1 -0
  88. package/dist/sub-languages/projection/HoverProvider.js +298 -0
  89. package/dist/sub-languages/projection/HoverProvider.js.map +1 -0
  90. package/dist/sub-languages/projection/Validator.d.ts +22 -0
  91. package/dist/sub-languages/projection/Validator.d.ts.map +1 -0
  92. package/dist/sub-languages/projection/Validator.js +339 -0
  93. package/dist/sub-languages/projection/Validator.js.map +1 -0
  94. package/dist/sub-languages/projection/index.d.ts +49 -0
  95. package/dist/sub-languages/projection/index.d.ts.map +1 -0
  96. package/dist/sub-languages/projection/index.js +160 -0
  97. package/dist/sub-languages/projection/index.js.map +1 -0
  98. package/dist/sub-languages/projection/language.d.ts +5 -0
  99. package/dist/sub-languages/projection/language.d.ts.map +1 -0
  100. package/dist/sub-languages/projection/language.js +171 -0
  101. package/dist/sub-languages/projection/language.js.map +1 -0
  102. package/dist/symbols.d.ts +45 -0
  103. package/dist/symbols.d.ts.map +1 -0
  104. package/dist/symbols.js +100 -0
  105. package/dist/symbols.js.map +1 -0
  106. package/dist/themes/screenplay-dark.d.ts +4 -0
  107. package/dist/themes/screenplay-dark.d.ts.map +1 -0
  108. package/dist/themes/screenplay-dark.js +29 -0
  109. package/dist/themes/screenplay-dark.js.map +1 -0
  110. package/dist/themes/screenplay-light.d.ts +4 -0
  111. package/dist/themes/screenplay-light.d.ts.map +1 -0
  112. package/dist/themes/screenplay-light.js +29 -0
  113. package/dist/themes/screenplay-light.js.map +1 -0
  114. package/dist/tokens.d.ts +5 -0
  115. package/dist/tokens.d.ts.map +1 -0
  116. package/dist/tokens.js +121 -0
  117. package/dist/tokens.js.map +1 -0
  118. package/dist/validation.d.ts +10 -0
  119. package/dist/validation.d.ts.map +1 -0
  120. package/dist/validation.js +101 -0
  121. package/dist/validation.js.map +1 -0
  122. package/document-context.ts +51 -0
  123. package/hover-content.ts +76 -0
  124. package/hover.ts +23 -0
  125. package/index.ts +98 -0
  126. package/keyword-docs.ts +102 -0
  127. package/language.ts +130 -0
  128. package/package.json +44 -0
  129. package/sub-language-registry.ts +42 -0
  130. package/sub-languages/capture/CompletionProvider.ts +365 -0
  131. package/sub-languages/capture/HoverProvider.ts +99 -0
  132. package/sub-languages/capture/Validator.ts +97 -0
  133. package/sub-languages/capture/index.ts +72 -0
  134. package/sub-languages/capture/language.ts +176 -0
  135. package/sub-languages/cdl.ts +76 -0
  136. package/sub-languages/pdl.ts +69 -0
  137. package/sub-languages/projection/CodeActionProvider.ts +123 -0
  138. package/sub-languages/projection/CompletionProvider.ts +653 -0
  139. package/sub-languages/projection/HoverProvider.ts +338 -0
  140. package/sub-languages/projection/Validator.ts +387 -0
  141. package/sub-languages/projection/index.ts +223 -0
  142. package/sub-languages/projection/language.ts +193 -0
  143. package/symbols.ts +161 -0
  144. package/themes/screenplay-dark.ts +32 -0
  145. package/themes/screenplay-light.ts +32 -0
  146. package/tokens.ts +145 -0
  147. package/validation.ts +145 -0
@@ -0,0 +1,653 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { editor, languages, IRange, Position } from 'monaco-editor';
5
+ import type { JsonSchema, JsonSchemaProperty, ReadModelInfo } from './index';
6
+
7
+ interface CompletionContext {
8
+ lineContent: string;
9
+ textBeforeCursor: string;
10
+ textAfterCursor: string;
11
+ currentWord: string;
12
+ indentLevel: number;
13
+ lineNumber: number;
14
+ }
15
+
16
+ export class CompletionProvider implements languages.CompletionItemProvider {
17
+ private readModels: ReadModelInfo[] = [];
18
+ private eventSchemas: Record<string, JsonSchema> = {};
19
+ private eventSequences: string[] = [];
20
+
21
+ setReadModels(readModels: ReadModelInfo[]): void {
22
+ this.readModels = readModels || [];
23
+ }
24
+
25
+ setEventSequences(sequences: string[]): void {
26
+ this.eventSequences = sequences || [];
27
+ }
28
+
29
+ // Keep for backwards compatibility
30
+ setReadModelSchemas(schemas: JsonSchema[]): void {
31
+ this.readModels = schemas.map(schema => ({
32
+ identifier: this.getSchemaName(schema) || '',
33
+ displayName: this.getSchemaName(schema) || '',
34
+ schema
35
+ }));
36
+ }
37
+
38
+ setEventSchemas(schemas: Record<string, JsonSchema> | JsonSchema[]): void {
39
+ if (!schemas) {
40
+ this.eventSchemas = {};
41
+ return;
42
+ }
43
+
44
+ if (Array.isArray(schemas)) {
45
+ const map: Record<string, JsonSchema> = {};
46
+ schemas.forEach((s, i) => {
47
+ if (!s) return;
48
+ const name = this.getSchemaName(s) || `Event${i + 1}`;
49
+ map[name] = s;
50
+ });
51
+ this.eventSchemas = map;
52
+ return;
53
+ }
54
+
55
+ this.eventSchemas = schemas;
56
+ }
57
+
58
+ provideCompletionItems(
59
+ model: editor.ITextModel,
60
+ position: Position
61
+ ): languages.ProviderResult<languages.CompletionList> {
62
+ const context = this.buildContext(model, position);
63
+ const suggestions: languages.CompletionItem[] = [];
64
+
65
+ // Get the active read model schema from the projection declaration
66
+ const activeSchema = this.getActiveReadModelSchema(model);
67
+
68
+ // Determine what kind of completions to provide based on context
69
+ if (context.lineNumber === 1) {
70
+ this.addProjectionLineCompletions(suggestions, context);
71
+ } else if (this.isAfterKeyword(context, 'sequence')) {
72
+ this.addEventSequenceCompletions(suggestions, context);
73
+ } else if (this.isAfterKeyword(context, 'from') || this.isAfterKeyword(context, 'every')) {
74
+ this.addEventTypeCompletions(suggestions, context);
75
+ } else if (this.isAfterKeyword(context, 'key')) {
76
+ this.addKeyCompletions(suggestions, context, activeSchema);
77
+ } else if (this.isAfterKeyword(context, 'parent')) {
78
+ this.addParentCompletions(suggestions, context);
79
+ } else if (this.isAfterKeyword(context, 'children')) {
80
+ this.addChildrenCompletions(suggestions, context, activeSchema);
81
+ } else if (this.isAfterKeyword(context, 'join')) {
82
+ this.addJoinCompletions(suggestions, context, activeSchema);
83
+ } else if (this.isAfterKeyword(context, 'with')) {
84
+ this.addEventTypeCompletions(suggestions, context);
85
+ } else if (this.isAfterNumericOperationKeyword(context)) {
86
+ this.addNumericPropertyCompletions(suggestions, context, activeSchema);
87
+ } else if (this.isAfterAddOrSubtractBy(context)) {
88
+ this.addAssignmentValueCompletions(suggestions, context, model, position);
89
+ } else if (this.isInAssignment(context)) {
90
+ this.addAssignmentValueCompletions(suggestions, context, model, position);
91
+ } else if (this.isPropertyDotAccess(context)) {
92
+ this.addPropertyMemberCompletions(suggestions, context);
93
+ } else if (this.isStartOfLine(context) || this.isAfterIndent(context)) {
94
+ this.addStatementCompletions(suggestions, context, activeSchema);
95
+ }
96
+
97
+ return { suggestions };
98
+ }
99
+
100
+ private buildContext(model: editor.ITextModel, position: Position): CompletionContext {
101
+ const lineContent = model.getLineContent(position.lineNumber);
102
+ const textBeforeCursor = lineContent.substring(0, position.column - 1);
103
+ const textAfterCursor = lineContent.substring(position.column - 1);
104
+ const word = model.getWordAtPosition(position);
105
+ const currentWord = word?.word || '';
106
+ const indentLevel = this.getIndentLevel(textBeforeCursor);
107
+
108
+ return {
109
+ lineContent,
110
+ textBeforeCursor,
111
+ textAfterCursor,
112
+ currentWord,
113
+ indentLevel,
114
+ lineNumber: position.lineNumber,
115
+ };
116
+ }
117
+
118
+ private getIndentLevel(text: string): number {
119
+ const match = text.match(/^(\s*)/);
120
+ return match ? Math.floor(match[1].length / 2) : 0;
121
+ }
122
+
123
+ private isStartOfLine(context: CompletionContext): boolean {
124
+ return context.textBeforeCursor.trim() === '';
125
+ }
126
+
127
+ private isAfterIndent(context: CompletionContext): boolean {
128
+ return /^\s+$/.test(context.textBeforeCursor);
129
+ }
130
+
131
+ private isAfterKeyword(context: CompletionContext, keyword: string): boolean {
132
+ const pattern = new RegExp(`\\b${keyword}\\s+\\w*$`);
133
+ return pattern.test(context.textBeforeCursor);
134
+ }
135
+
136
+ private isAfterNumericOperationKeyword(context: CompletionContext): boolean {
137
+ // Match: count, increment, decrement, add, subtract followed by optional word (but not "by")
138
+ // For add/subtract, we only match if "by" hasn't been typed yet
139
+ const countIncrDecrPattern = /\b(count|increment|decrement)\s+\w*$/;
140
+ const addSubtractPattern = /\b(add|subtract)\s+\w*$/;
141
+
142
+ // Check for count/increment/decrement first
143
+ if (countIncrDecrPattern.test(context.textBeforeCursor)) {
144
+ return true;
145
+ }
146
+
147
+ // For add/subtract, only match if "by" hasn't been typed yet
148
+ if (addSubtractPattern.test(context.textBeforeCursor)) {
149
+ // Make sure we're not after "by"
150
+ return !this.isAfterAddOrSubtractBy(context);
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ private isAfterAddOrSubtractBy(context: CompletionContext): boolean {
157
+ // Match: "add <property> by " or "subtract <property> by " followed by optional word
158
+ const pattern = /\b(add|subtract)\s+\w+\s+by\s+\w*$/;
159
+ return pattern.test(context.textBeforeCursor);
160
+ }
161
+
162
+ private isInAssignment(context: CompletionContext): boolean {
163
+ return /\w+\s*=\s*\w*$/.test(context.textBeforeCursor);
164
+ }
165
+
166
+ private isPropertyDotAccess(context: CompletionContext): boolean {
167
+ return /(\w+|\$\w+)\.(\w*)$/.test(context.textBeforeCursor);
168
+ }
169
+
170
+ private addProjectionLineCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void {
171
+ const trimmed = context.textBeforeCursor.trim();
172
+
173
+ // Suggest "projection" keyword
174
+ if (trimmed === '' || 'projection'.startsWith(trimmed)) {
175
+ suggestions.push({
176
+ label: 'projection',
177
+ kind: 14, // Keyword
178
+ insertText: 'projection ',
179
+ documentation: 'Define a projection that transforms events into a read model',
180
+ detail: 'projection <ProjectionName>',
181
+ range: this.getRangeForWord(context),
182
+ });
183
+ }
184
+
185
+ // Check if cursor is after "=>" to suggest read model names
186
+ const arrowMatch = trimmed.match(/^projection\s+[\w.]+\s*=>\s*([\w.]*)$/);
187
+ if (arrowMatch) {
188
+ const partialReadModel = arrowMatch[1] || '';
189
+ this.readModels.forEach((readModel) => {
190
+ if (!partialReadModel || readModel.displayName.toLowerCase().startsWith(partialReadModel.toLowerCase())) {
191
+ const label = `${readModel.displayName} (${readModel.identifier})`;
192
+ suggestions.push({
193
+ label,
194
+ kind: 6, // Class
195
+ insertText: readModel.displayName,
196
+ filterText: `${readModel.displayName} ${readModel.identifier}`,
197
+ documentation: `Read model: ${readModel.displayName} (${readModel.identifier})`,
198
+ detail: readModel.identifier,
199
+ range: this.getRangeForWord(context),
200
+ });
201
+ }
202
+ });
203
+ return;
204
+ }
205
+
206
+ // After "projection ", suggest read model names (for quick completion of both projection name and read model)
207
+ if (trimmed.startsWith('projection ') && !trimmed.includes('=>')) {
208
+ const afterProjection = trimmed.substring('projection '.length);
209
+ this.readModels.forEach((readModel) => {
210
+ if (!afterProjection || readModel.displayName.toLowerCase().startsWith(afterProjection.toLowerCase())) {
211
+ const label = `${readModel.displayName} (${readModel.identifier})`;
212
+ suggestions.push({
213
+ label,
214
+ kind: 6, // Class
215
+ insertText: `${readModel.displayName} => ${readModel.displayName}`,
216
+ filterText: `${readModel.displayName} ${readModel.identifier}`,
217
+ documentation: `Read model: ${readModel.displayName} (${readModel.identifier})`,
218
+ detail: readModel.identifier,
219
+ range: this.getRangeForWord(context),
220
+ });
221
+ }
222
+ });
223
+ }
224
+ }
225
+
226
+ private addEventTypeCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void {
227
+ Object.keys(this.eventSchemas).forEach((eventName) => {
228
+ if (!context.currentWord || eventName.startsWith(context.currentWord)) {
229
+ const schema = this.eventSchemas[eventName];
230
+ suggestions.push({
231
+ label: eventName,
232
+ kind: 6, // Class
233
+ insertText: eventName,
234
+ documentation: this.getSchemaDescription(schema) || `Event type: ${eventName}`,
235
+ detail: 'Event Type',
236
+ range: this.getRangeForWord(context),
237
+ });
238
+ }
239
+ });
240
+ }
241
+
242
+ private addEventSequenceCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void {
243
+ this.eventSequences.forEach((sequenceId) => {
244
+ if (!context.currentWord || sequenceId.startsWith(context.currentWord)) {
245
+ suggestions.push({
246
+ label: sequenceId,
247
+ kind: 13, // Value
248
+ insertText: sequenceId,
249
+ documentation: `Event sequence: ${sequenceId}`,
250
+ detail: 'Event Sequence',
251
+ range: this.getRangeForWord(context),
252
+ });
253
+ }
254
+ });
255
+ }
256
+
257
+ private addKeyCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void {
258
+ // Check if this might be a composite key (type reference)
259
+ if (activeSchema?.definitions) {
260
+ Object.keys(activeSchema.definitions).forEach((typeName) => {
261
+ const typeDef = activeSchema.definitions![typeName];
262
+ if (typeDef.type === 'object') {
263
+ suggestions.push({
264
+ label: typeName,
265
+ kind: 6, // Class
266
+ insertText: typeName,
267
+ documentation: `Composite key type: ${typeName}`,
268
+ detail: 'Composite Key Type',
269
+ range: this.getRangeForWord(context),
270
+ });
271
+ }
272
+ });
273
+ }
274
+
275
+ // Suggest built-in expressions for simple keys
276
+ this.addExpressionCompletions(suggestions, context);
277
+ }
278
+
279
+ private addParentCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void {
280
+ this.addExpressionCompletions(suggestions, context);
281
+ }
282
+
283
+ private addNumericPropertyCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void {
284
+ // After "count ", "increment ", or "decrement ", suggest numeric properties from read model
285
+ if (activeSchema?.properties) {
286
+ Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => {
287
+ // Suggest numeric properties (integer or number types)
288
+ const isNumeric = propSchema.type === 'integer' || propSchema.type === 'number';
289
+ if (isNumeric) {
290
+ if (!context.currentWord || propName.startsWith(context.currentWord)) {
291
+ suggestions.push({
292
+ label: propName,
293
+ kind: 9, // Property
294
+ insertText: propName,
295
+ documentation: `Numeric property: ${propName}`,
296
+ detail: this.getPropertyTypeDescription(propSchema),
297
+ range: this.getRangeForWord(context),
298
+ });
299
+ }
300
+ }
301
+ });
302
+ }
303
+
304
+ // Also check if "add" or "subtract" is being typed - need to handle "add <prop> by" pattern
305
+ const addSubtractMatch = context.textBeforeCursor.match(/\b(add|subtract)\s+(\w*)$/);
306
+ if (addSubtractMatch) {
307
+ const partialProp = addSubtractMatch[2] || '';
308
+ if (activeSchema?.properties) {
309
+ Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => {
310
+ const isNumeric = propSchema.type === 'integer' || propSchema.type === 'number';
311
+ if (isNumeric && (!partialProp || propName.startsWith(partialProp))) {
312
+ suggestions.push({
313
+ label: propName,
314
+ kind: 9, // Property
315
+ insertText: `${propName} by `,
316
+ documentation: `Numeric property: ${propName}`,
317
+ detail: this.getPropertyTypeDescription(propSchema),
318
+ range: this.getRangeForWord(context),
319
+ });
320
+ }
321
+ });
322
+ }
323
+ }
324
+ }
325
+
326
+ private addChildrenCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void {
327
+ // After "children ", suggest collection properties from read model
328
+ if (activeSchema?.properties) {
329
+ Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => {
330
+ // Collections are typically arrays
331
+ if (propSchema.type === 'array' || propSchema.items) {
332
+ suggestions.push({
333
+ label: propName,
334
+ kind: 9, // Property
335
+ insertText: `${propName} id `,
336
+ documentation: `Collection property: ${propName}`,
337
+ detail: this.getPropertyTypeDescription(propSchema),
338
+ range: this.getRangeForWord(context),
339
+ });
340
+ }
341
+ });
342
+ }
343
+ }
344
+
345
+ private addJoinCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void {
346
+ const trimmed = context.textBeforeCursor.trim();
347
+
348
+ if (trimmed === 'join' || !trimmed.includes(' ')) {
349
+ // Suggest collection properties
350
+ if (activeSchema?.properties) {
351
+ Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => {
352
+ if (propSchema.type === 'array' || propSchema.items) {
353
+ suggestions.push({
354
+ label: propName,
355
+ kind: 9,
356
+ insertText: `${propName} on `,
357
+ documentation: `Join on collection: ${propName}`,
358
+ range: this.getRangeForWord(context),
359
+ });
360
+ }
361
+ });
362
+ }
363
+ } else if (trimmed.match(/join\s+\w+\s+on\s+/)) {
364
+ // After "on", suggest properties to join on
365
+ if (activeSchema?.properties) {
366
+ Object.keys(activeSchema.properties).forEach((propName) => {
367
+ suggestions.push({
368
+ label: propName,
369
+ kind: 9,
370
+ insertText: propName,
371
+ documentation: `Property: ${propName}`,
372
+ range: this.getRangeForWord(context),
373
+ });
374
+ });
375
+ }
376
+ }
377
+ }
378
+
379
+ private addStatementCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void {
380
+ // Add all statement keywords
381
+ const keywords = [
382
+ { label: 'sequence', insertText: 'sequence ', documentation: 'Specify which event sequence to use for this projection', detail: 'sequence <event-sequence-id>' },
383
+ { label: 'from', insertText: 'from ', documentation: 'Handle specific event types', detail: 'from <EventType>' },
384
+ { label: 'all', insertText: 'all', documentation: 'Subscribe to all event types in the system — mappings run for every event system-wide, regardless of explicit from blocks', detail: 'all' },
385
+ { label: 'every', insertText: 'every', documentation: 'Handle all events for automatic mapping (only events explicitly subscribed to via from blocks)', detail: 'every' },
386
+ { label: 'key', insertText: 'key ', documentation: 'Define the projection key', detail: 'key <expression>' },
387
+ { label: 'parent', insertText: 'parent ', documentation: 'Define parent relationship', detail: 'parent <expression>' },
388
+ { label: 'join', insertText: 'join ', documentation: 'Join with child collection', detail: 'join <collection> on <property>' },
389
+ { label: 'children', insertText: 'children ', documentation: 'Define child collection operations', detail: 'children <collection> identified by <expression>' },
390
+ { label: 'remove with', insertText: 'remove with ', documentation: 'Remove items based on event', detail: 'remove with <EventType>' },
391
+ { label: 'remove via', insertText: 'remove via join on ', documentation: 'Remove items via join', detail: 'remove via join on <EventType>' },
392
+ { label: 'no automap', insertText: 'no automap', documentation: 'Disable automatic property mapping', detail: 'no automap' },
393
+ { label: 'increment', insertText: 'increment ', documentation: 'Increment a numeric property', detail: 'increment <property>' },
394
+ { label: 'decrement', insertText: 'decrement ', documentation: 'Decrement a numeric property', detail: 'decrement <property>' },
395
+ { label: 'count', insertText: 'count ', documentation: 'Count occurrences', detail: 'count <property>' },
396
+ { label: 'add', insertText: 'add ', documentation: 'Add value to property', detail: 'add <property> by <expression>' },
397
+ { label: 'subtract', insertText: 'subtract ', documentation: 'Subtract value from property', detail: 'subtract <property> by <expression>' },
398
+ { label: 'exclude children', insertText: 'exclude children', documentation: 'Exclude child collections from automap', detail: 'exclude children' },
399
+ ];
400
+
401
+ keywords.forEach((kw) => {
402
+ if (!context.currentWord || kw.label.startsWith(context.currentWord)) {
403
+ suggestions.push({
404
+ label: kw.label,
405
+ kind: 14, // Keyword
406
+ insertText: kw.insertText,
407
+ documentation: kw.documentation,
408
+ detail: kw.detail,
409
+ range: this.getRangeForWord(context),
410
+ });
411
+ }
412
+ });
413
+
414
+ // Add property assignments
415
+ if (activeSchema?.properties) {
416
+ Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => {
417
+ if (!context.currentWord || propName.startsWith(context.currentWord)) {
418
+ suggestions.push({
419
+ label: propName,
420
+ kind: 9, // Property
421
+ insertText: `${propName} = `,
422
+ documentation: `Assign value to property: ${propName}`,
423
+ detail: this.getPropertyTypeDescription(propSchema),
424
+ range: this.getRangeForWord(context),
425
+ });
426
+ }
427
+ });
428
+ }
429
+ }
430
+
431
+ private addAssignmentValueCompletions(
432
+ suggestions: languages.CompletionItem[],
433
+ context: CompletionContext,
434
+ model: editor.ITextModel,
435
+ position: Position
436
+ ): void {
437
+ // Get the event type from the current block
438
+ const eventType = this.getCurrentEventType(model, position.lineNumber);
439
+
440
+ // Suggest event properties if we know the event type
441
+ if (eventType && this.eventSchemas[eventType]) {
442
+ const eventSchema = this.eventSchemas[eventType];
443
+ if (eventSchema.properties) {
444
+ Object.entries(eventSchema.properties).forEach(([propName, propSchema]) => {
445
+ suggestions.push({
446
+ label: propName,
447
+ kind: 9,
448
+ insertText: propName,
449
+ documentation: `Event property: ${propName}`,
450
+ detail: this.getPropertyTypeDescription(propSchema),
451
+ range: this.getRangeForWord(context),
452
+ });
453
+ });
454
+ }
455
+ }
456
+
457
+ // Add expression completions
458
+ this.addExpressionCompletions(suggestions, context);
459
+ }
460
+
461
+ private addExpressionCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void {
462
+ const expressions = [
463
+ {
464
+ label: '$eventSourceId',
465
+ insertText: '$eventSourceId',
466
+ documentation: 'The identifier of the event source (aggregate/entity)',
467
+ detail: 'Event Source Expression'
468
+ },
469
+ {
470
+ label: '$causedBy',
471
+ insertText: '$causedBy',
472
+ documentation: 'Information about who/what caused the event',
473
+ detail: 'Identity Expression',
474
+ children: [
475
+ { name: 'subject', doc: 'The subject identifier' },
476
+ { name: 'name', doc: 'The display name' },
477
+ { name: 'userName', doc: 'The username' },
478
+ ]
479
+ },
480
+ {
481
+ label: '$eventContext',
482
+ insertText: '$eventContext.',
483
+ documentation: 'Access event context properties',
484
+ detail: 'Event Context Expression',
485
+ children: [
486
+ { name: 'sequenceNumber', doc: 'The event sequence number' },
487
+ { name: 'occurred', doc: 'When the event occurred' },
488
+ { name: 'eventSourceId', doc: 'The event source identifier' },
489
+ { name: 'namespace', doc: 'The event namespace' },
490
+ ]
491
+ },
492
+ {
493
+ label: '$occurred',
494
+ insertText: '$occurred',
495
+ documentation: 'Timestamp when the event occurred',
496
+ detail: 'Occurred Expression'
497
+ },
498
+ {
499
+ label: '$namespace',
500
+ insertText: '$namespace',
501
+ documentation: 'The namespace of the event',
502
+ detail: 'Namespace Expression'
503
+ },
504
+ ];
505
+
506
+ expressions.forEach((expr) => {
507
+ if (!context.currentWord || expr.label.startsWith(context.currentWord) || expr.label.startsWith('$' + context.currentWord)) {
508
+ suggestions.push({
509
+ label: expr.label,
510
+ kind: 14, // Keyword/builtin
511
+ insertText: expr.insertText,
512
+ documentation: expr.documentation,
513
+ detail: expr.detail,
514
+ range: this.getRangeForWord(context),
515
+ });
516
+ }
517
+ });
518
+ }
519
+
520
+ private addPropertyMemberCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void {
521
+ const match = context.textBeforeCursor.match(/(\w+|\$\w+)\.(\w*)$/);
522
+ if (!match) return;
523
+
524
+ const [, objectName, partialProp] = match;
525
+
526
+ // Handle $causedBy properties
527
+ if (objectName === '$causedBy') {
528
+ const causedByProps = [
529
+ { name: 'subject', doc: 'The subject identifier', type: 'string' },
530
+ { name: 'name', doc: 'The display name', type: 'string' },
531
+ { name: 'userName', doc: 'The username', type: 'string' },
532
+ ];
533
+
534
+ causedByProps.forEach((prop) => {
535
+ if (!partialProp || prop.name.startsWith(partialProp)) {
536
+ suggestions.push({
537
+ label: prop.name,
538
+ kind: 9,
539
+ insertText: prop.name,
540
+ documentation: prop.doc,
541
+ detail: prop.type,
542
+ range: this.getRangeForWord(context),
543
+ });
544
+ }
545
+ });
546
+ return;
547
+ }
548
+
549
+ // Handle $eventContext properties
550
+ if (objectName === '$eventContext') {
551
+ const contextProps = [
552
+ { name: 'sequenceNumber', doc: 'The event sequence number', type: 'EventSequenceNumber' },
553
+ { name: 'occurred', doc: 'When the event occurred', type: 'DateTimeOffset' },
554
+ { name: 'eventSourceId', doc: 'The event source identifier', type: 'string' },
555
+ { name: 'namespace', doc: 'The event namespace', type: 'string' },
556
+ ];
557
+
558
+ contextProps.forEach((prop) => {
559
+ if (!partialProp || prop.name.startsWith(partialProp)) {
560
+ suggestions.push({
561
+ label: prop.name,
562
+ kind: 9,
563
+ insertText: prop.name,
564
+ documentation: prop.doc,
565
+ detail: prop.type,
566
+ range: this.getRangeForWord(context),
567
+ });
568
+ }
569
+ });
570
+ return;
571
+ }
572
+
573
+ // Handle event type properties
574
+ const eventSchema = this.eventSchemas[objectName];
575
+ if (eventSchema?.properties) {
576
+ Object.entries(eventSchema.properties).forEach(([propName, propSchema]) => {
577
+ if (!partialProp || propName.startsWith(partialProp)) {
578
+ suggestions.push({
579
+ label: propName,
580
+ kind: 9,
581
+ insertText: propName,
582
+ documentation: `Event property: ${propName}`,
583
+ detail: this.getPropertyTypeDescription(propSchema),
584
+ range: this.getRangeForWord(context),
585
+ });
586
+ }
587
+ });
588
+ }
589
+ }
590
+
591
+ private getCurrentEventType(model: editor.ITextModel, lineNumber: number): string | null {
592
+ // Search backwards to find the event type declaration
593
+ for (let i = lineNumber; i >= 1; i--) {
594
+ const line = model.getLineContent(i).trim();
595
+ const fromMatch = line.match(/^from\s+([\w-]+)/);
596
+ if (fromMatch) {
597
+ return fromMatch[1];
598
+ }
599
+ const everyMatch = line.match(/^every$/);
600
+ if (everyMatch) {
601
+ return null; // every block doesn't have a specific event type
602
+ }
603
+ }
604
+ return null;
605
+ }
606
+
607
+ private getActiveReadModelSchema(model: editor.ITextModel): JsonSchema | undefined {
608
+ const firstLine = model.getLineContent(1).trim();
609
+ const match = firstLine.match(/^projection\s+[\w.]+\s*=>\s*([\w.]+)/);
610
+ if (!match) return undefined;
611
+
612
+ const readModelName = match[1];
613
+ const readModel = this.resolveReadModelInfo(readModelName);
614
+ return readModel?.schema;
615
+ }
616
+
617
+ private resolveReadModelInfo(readModelToken: string): ReadModelInfo | null {
618
+ return this.readModels.find(rm => rm.identifier === readModelToken || rm.displayName === readModelToken) || null;
619
+ }
620
+
621
+ private getSchemaName(schema: JsonSchema): string | undefined {
622
+ if (schema.name) return schema.name;
623
+ if (schema.title) return schema.title;
624
+ if (typeof schema.$id === 'string') {
625
+ const parts = schema.$id.split('/');
626
+ return parts[parts.length - 1] || schema.$id;
627
+ }
628
+ return undefined;
629
+ }
630
+
631
+ private getSchemaDescription(schema: JsonSchema): string | undefined {
632
+ return schema.description || undefined;
633
+ }
634
+
635
+ private getPropertyTypeDescription(propSchema: JsonSchemaProperty): string {
636
+ if (propSchema.type === 'array' && propSchema.items) {
637
+ const itemType = propSchema.items.type || propSchema.items.$id?.split('/').pop() || 'unknown';
638
+ return `${itemType}[]`;
639
+ }
640
+ return propSchema.type || propSchema.$ref?.split('/').pop() || 'unknown';
641
+ }
642
+
643
+ private getRangeForWord(context: CompletionContext): IRange {
644
+ const startColumn = context.textBeforeCursor.length - context.currentWord.length + 1;
645
+ const endColumn = startColumn + context.currentWord.length;
646
+ return {
647
+ startLineNumber: context.lineNumber,
648
+ startColumn: Math.max(1, startColumn),
649
+ endLineNumber: context.lineNumber,
650
+ endColumn: Math.max(1, endColumn),
651
+ };
652
+ }
653
+ }