From 3e38f732cccb83d0bdcabf1ad98846d0476f56ec Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 29 Jul 2026 06:04:27 +0200 Subject: [PATCH 1/3] Extract shared Monarch token rules from the composite tokenizer Pull the string/number/operator/variable common rules used by every sub-language block in the composite `.play` tokenizer into an exported commonTokenRules constant, so a standalone sub-language module can reuse the same fragments instead of duplicating them. --- .../Monaco/screenplay-language/tokens.ts | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/Source/Screenplay/Monaco/screenplay-language/tokens.ts b/Source/Screenplay/Monaco/screenplay-language/tokens.ts index 0e6f2b7..6e4f7e7 100644 --- a/Source/Screenplay/Monaco/screenplay-language/tokens.ts +++ b/Source/Screenplay/Monaco/screenplay-language/tokens.ts @@ -32,6 +32,23 @@ function subLanguageExitRule(subLanguages: SubLanguage[]): MonarchTokenRules[num ]; } +// Shared across the composite `.play` tokenizer and every standalone sub-language tokenizer +// (comments, `$`-prefixed context variables, strings, numbers, operators, delimiters, whitespace). +export const commonTokenRules: MonarchTokenRules = [ + [/\/\/.*$/, 'comment'], + [/\$(?:context|eventContext|eventSourceId|causedBy)(?:\.\w+)*/, 'variable.predefined'], + [/\$(?:env|secrets|strings)\.[\w.]+/, 'variable.predefined'], + [/\$\.[\w.]*/, 'variable.predefined'], + [/"[^"]*"/, 'string'], + [/"[^"]*$/, 'string.invalid'], + [/\d+(?:ms|s|m|h|d)\b/, 'number'], + [/-?\d+\.\d+/, 'number.float'], + [/-?\d+/, 'number'], + [/=>|==|!=|>=|<=|[><=:?]/, 'operator'], + [/[[\](),.]/, 'delimiter'], + [/\s+/, 'white'], +]; + export function createTokensProvider(subLanguages: SubLanguage[]): languages.IMonarchLanguage { const tokenizer: Record = { root: [ @@ -75,20 +92,7 @@ export function createTokensProvider(subLanguages: SubLanguage[]): languages.IMo { include: '@common' }, ], - common: [ - [/\/\/.*$/, 'comment'], - [/\$(?:context|eventContext|eventSourceId|causedBy)(?:\.\w+)*/, 'variable.predefined'], - [/\$(?:env|secrets|strings)\.[\w.]+/, 'variable.predefined'], - [/\$\.[\w.]*/, 'variable.predefined'], - [/"[^"]*"/, 'string'], - [/"[^"]*$/, 'string.invalid'], - [/\d+(?:ms|s|m|h|d)\b/, 'number'], - [/-?\d+\.\d+/, 'number.float'], - [/-?\d+/, 'number'], - [/=>|==|!=|>=|<=|[><=:?]/, 'operator'], - [/[[\](),.]/, 'delimiter'], - [/\s+/, 'white'], - ], + common: commonTokenRules, // After a code tag, the only thing allowed before the opening fence is whitespace. codeBlockPending: [ From 0af81935c8c680a23216e236eec8390a1b501af1 Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 29 Jul 2026 06:04:47 +0200 Subject: [PATCH 2/3] Add standalone Capture Declaration Language and Projection Declaration Language modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register CDL and PDL as their own top-level Monaco languages (own language id, tokenizer, completion/hover/validation providers) rather than only as sub-grammars embedded inside a `.play` document, so a consumer can use just one of them without pulling in the rest of the Screenplay construct set. Exposed as the `./capture` and `./projection` subpath exports. Port the full richness of Chronicle's existing hand-rolled Monaco tooling for both languages (every completion context, hover documentation, and validation rule), correcting CDL's keyword surface to the current grammar in the process — `auth`/`bearer`/inline `url` are gone (an API source now references a named External Service via `api `, with an optional `route`), which the previously duplicated Chronicle-side tooling had drifted from. PDL's schema-aware behavior (event/read-model completions, hover, and validation) is parameterized through data setters and callbacks that a host application feeds live data into, mirroring the exact shape Chronicle's existing setter functions already used. Also bump the monaco-editor devDependency to match the version range downstream consumers use, avoiding a type-shape mismatch when this package is portal-linked for local development. --- .../Monaco/screenplay-language/package.json | 10 +- .../capture/CompletionProvider.ts | 365 ++++++++++ .../sub-languages/capture/HoverProvider.ts | 99 +++ .../sub-languages/capture/Validator.ts | 97 +++ .../sub-languages/capture/index.ts | 72 ++ .../sub-languages/capture/language.ts | 176 +++++ .../screenplay-language/sub-languages/cdl.ts | 15 +- .../projection/CodeActionProvider.ts | 123 ++++ .../projection/CompletionProvider.ts | 653 ++++++++++++++++++ .../sub-languages/projection/HoverProvider.ts | 338 +++++++++ .../sub-languages/projection/Validator.ts | 387 +++++++++++ .../sub-languages/projection/index.ts | 223 ++++++ .../sub-languages/projection/language.ts | 193 ++++++ 13 files changed, 2745 insertions(+), 6 deletions(-) create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/CompletionProvider.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/HoverProvider.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/Validator.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/index.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/language.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/CodeActionProvider.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/CompletionProvider.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/HoverProvider.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/Validator.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/index.ts create mode 100644 Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/language.ts diff --git a/Source/Screenplay/Monaco/screenplay-language/package.json b/Source/Screenplay/Monaco/screenplay-language/package.json index 597b689..4fd2d89 100644 --- a/Source/Screenplay/Monaco/screenplay-language/package.json +++ b/Source/Screenplay/Monaco/screenplay-language/package.json @@ -11,6 +11,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./capture": { + "types": "./dist/sub-languages/capture/index.d.ts", + "import": "./dist/sub-languages/capture/index.js" + }, + "./projection": { + "types": "./dist/sub-languages/projection/index.d.ts", + "import": "./dist/sub-languages/projection/index.js" } }, "files": [ @@ -27,7 +35,7 @@ "monaco-editor": ">=0.44.0" }, "devDependencies": { - "monaco-editor": "^0.52.2", + "monaco-editor": "^0.55.1", "typescript": "6.0.3" } } diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/CompletionProvider.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/CompletionProvider.ts new file mode 100644 index 0000000..a7f0c08 --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/CompletionProvider.ts @@ -0,0 +1,365 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { languages, type editor, type Position, type IRange } from 'monaco-editor'; + +interface CompletionContext { + textBeforeCursor: string; + currentWord: string; + indentLevel: number; + lineNumber: number; + wordStartColumn: number; + wordEndColumn: number; +} + +const commonPropertyPaths = [ + 'id', + 'status', + 'firstName', + 'lastName', + 'fullName', + 'address.street', + 'address.city', + 'lineItems', + 'lineItems.quantity', + 'lineNumber', + 'quantity', +]; + +export class CompletionProvider implements languages.CompletionItemProvider { + triggerCharacters = ['.', ' ', '=', '$']; + + provideCompletionItems( + model: editor.ITextModel, + position: Position, + ): languages.ProviderResult { + const context = this.buildContext(model, position); + const suggestions: languages.CompletionItem[] = []; + + if (context.lineNumber === 1) { + this.addCaptureLineCompletions(suggestions, context); + } else if (this.isAfterKeyword(context, 'capture')) { + this.addCaptureNameCompletions(suggestions, context); + } else if (this.isAfterKeyword(context, 'source')) { + this.addSourceTypeCompletions(suggestions, context); + } else if (this.isAfterAppendKeyword(context)) { + this.addAppendNameCompletions(suggestions, context); + } else if (this.isAfterWhenKeyword(context)) { + this.addWhenCompletions(suggestions, context); + } else if (this.isAfterWhenFrom(context)) { + this.addFromValueCompletions(suggestions, context); + } else if (this.isAfterWhenTo(context)) { + this.addToValueCompletions(suggestions, context); + } else if (this.isAfterContextPrefix(context, '$.')) { + this.addPathCompletions(suggestions, context, '$.'); + } else if (this.isAfterContextPrefix(context, '$previous.')) { + this.addPathCompletions(suggestions, context, '$previous.'); + } else if (this.isAfterContextPrefix(context, '$context.')) { + this.addContextCompletions(suggestions, context); + } else if (this.isAfterContextPrefix(context, '$env.')) { + this.addEnvironmentCompletions(suggestions, context); + } else if (this.isAfterAssignment(context)) { + this.addAssignmentValueCompletions(suggestions, context); + } else if ((this.isStartOfLine(context) || this.isAfterIndent(context)) && context.indentLevel === 1) { + this.addTopLevelStatementCompletions(suggestions, context); + } else if ((this.isStartOfLine(context) || this.isAfterIndent(context)) && context.indentLevel >= 2) { + this.addSourceBlockCompletions(suggestions, context, this.getCurrentSourceType(model, position.lineNumber)); + } + + return { suggestions }; + } + + private buildContext(model: editor.ITextModel, position: Position): CompletionContext { + const lineContent = model.getLineContent(position.lineNumber); + const textBeforeCursor = lineContent.substring(0, position.column - 1); + const word = model.getWordUntilPosition(position); + + return { + textBeforeCursor, + currentWord: word.word || '', + indentLevel: this.getIndentLevel(textBeforeCursor), + lineNumber: position.lineNumber, + wordStartColumn: word.startColumn, + wordEndColumn: word.endColumn, + }; + } + + private getIndentLevel(text: string): number { + const match = text.match(/^(\s*)/); + return match ? Math.floor(match[1].length / 2) : 0; + } + + private isStartOfLine(context: CompletionContext): boolean { + return context.textBeforeCursor.trim() === ''; + } + + private isAfterIndent(context: CompletionContext): boolean { + return /^\s+$/.test(context.textBeforeCursor); + } + + private isAfterKeyword(context: CompletionContext, keyword: string): boolean { + const pattern = new RegExp(`\\b${keyword}\\s+\\w*$`); + return pattern.test(context.textBeforeCursor); + } + + private isAfterAppendKeyword(context: CompletionContext): boolean { + return /\bappend\s+[\w.]*$/.test(context.textBeforeCursor); + } + + private isAfterWhenKeyword(context: CompletionContext): boolean { + return /\bwhen\s+[\w.]*$/.test(context.textBeforeCursor); + } + + private isAfterWhenFrom(context: CompletionContext): boolean { + return /\bwhen\s+[\w.]+\s+from\s+[^\s]*$/.test(context.textBeforeCursor); + } + + private isAfterWhenTo(context: CompletionContext): boolean { + return /\bwhen\s+[\w.]+\s+from\s+\S+\s+to\s+[^\s]*$/.test(context.textBeforeCursor); + } + + private isAfterAssignment(context: CompletionContext): boolean { + return /=\s*([$\w.]*)$/.test(context.textBeforeCursor); + } + + private isAfterContextPrefix(context: CompletionContext, prefix: string): boolean { + return context.textBeforeCursor.endsWith(prefix); + } + + private addCaptureLineCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + const trimmed = context.textBeforeCursor.trim(); + if (trimmed === '' || 'capture'.startsWith(trimmed)) { + suggestions.push({ + label: 'capture', + kind: languages.CompletionItemKind.Keyword, + insertText: 'capture ', + documentation: 'Define a capture declaration', + detail: 'capture ', + range: this.getRangeForWord(context), + }); + } + + if (trimmed.startsWith('capture ')) { + this.addCaptureNameCompletions(suggestions, context); + } + } + + private addCaptureNameCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + suggestions.push({ + label: 'CaptureDefinition', + kind: languages.CompletionItemKind.Class, + insertText: 'CaptureDefinition', + documentation: 'A descriptive name for this capture declaration', + detail: 'Capture name', + range: this.getRangeForWord(context), + }); + } + + private addSourceTypeCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + [ + { label: 'api', detail: 'source api', documentation: 'Capture data by polling an HTTP API' }, + { label: 'webhook', detail: 'source webhook', documentation: 'Capture data from incoming webhook requests' }, + { label: 'message', detail: 'source message', documentation: 'Capture data from a message topic' }, + ].forEach((source) => { + suggestions.push({ + label: source.label, + kind: languages.CompletionItemKind.Keyword, + insertText: source.label, + documentation: source.documentation, + detail: source.detail, + range: this.getRangeForWord(context), + }); + }); + } + + // Per source type — no `auth`/`bearer`/`url`: an API source references a named + // External Service (`api `) for base URL and auth; a webhook source's + // authentication is configured on the source builder, not in CDL text. + private addSourceBlockCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, sourceType: string | null): void { + const options: Record> = { + api: [ + { label: 'api', detail: 'api ', documentation: 'References a configured External Service by name for the base URL and authentication' }, + { label: 'route', detail: 'route ', documentation: 'Optional path appended to the External Service base URL; if omitted, the base URL is used as-is' }, + { label: 'poll', detail: 'poll ', documentation: 'The polling interval, for example 5m' }, + ], + webhook: [ + { label: 'path', detail: 'path ', documentation: 'The inbound path this webhook listens on' }, + ], + message: [ + { label: 'topic', detail: 'topic ', documentation: 'The topic or subject to subscribe to' }, + ], + }; + + (sourceType ? options[sourceType] ?? [] : []).forEach((option) => { + suggestions.push({ + label: option.label, + kind: languages.CompletionItemKind.Keyword, + insertText: `${option.label} `, + documentation: option.documentation, + detail: option.detail, + range: this.getRangeForWord(context), + }); + }); + } + + private addTopLevelStatementCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + [ + { label: 'key', insertText: 'key ', detail: 'key ', documentation: 'Identify the captured entity' }, + { label: 'map', insertText: 'map', detail: 'map', documentation: 'Map captured fields into a normalized shape' }, + { label: 'append', insertText: 'append ', detail: 'append ', documentation: 'Append an event when a condition matches' }, + { label: 'nested', insertText: 'nested ', detail: 'nested ', documentation: 'Create a nested mapping scope' }, + { label: 'children', insertText: 'children ', detail: 'children identified by ', documentation: 'Handle child collections independently' }, + ].forEach((statement) => { + suggestions.push({ + label: statement.label, + kind: languages.CompletionItemKind.Keyword, + insertText: statement.insertText, + documentation: statement.documentation, + detail: statement.detail, + range: this.getRangeForWord(context), + }); + }); + } + + private addAppendNameCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + suggestions.push({ + label: 'ItemChanged', + kind: languages.CompletionItemKind.Class, + insertText: 'ItemChanged', + documentation: 'A generic event name for the appended event', + detail: 'Event name', + range: this.getRangeForWord(context), + }); + } + + private addWhenCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + [ + { label: 'added', insertText: 'added', detail: 'when added', documentation: 'Append when a child item was added' }, + { label: 'removed', insertText: 'removed', detail: 'when removed', documentation: 'Append when a child item was removed' }, + { label: 'status', insertText: 'status', detail: 'when ', documentation: 'Append when a property changed' }, + { label: 'status transition', insertText: 'status from inactive to active', detail: 'when from to ', documentation: 'Append when a property changed between two values' }, + { label: 'quantity', insertText: 'quantity', detail: 'when ', documentation: 'Append when a property changed' }, + { label: 'lineNumber', insertText: 'lineNumber', detail: 'when ', documentation: 'Append when a specific property changed' }, + ].forEach((option) => { + suggestions.push({ + label: option.label, + kind: languages.CompletionItemKind.Keyword, + insertText: option.insertText, + documentation: option.documentation, + detail: option.detail, + range: this.getRangeForWord(context), + }); + }); + } + + private addFromValueCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + [ + { label: '*', insertText: '*', detail: 'Wildcard', documentation: 'Match any previous value' }, + { label: 'inactive', insertText: 'inactive', detail: 'Common value', documentation: 'A common previous state value' }, + { label: 'active', insertText: 'active', detail: 'Common value', documentation: 'A common previous state value' }, + { label: 'true', insertText: 'true', detail: 'Boolean value', documentation: 'Boolean true' }, + { label: 'false', insertText: 'false', detail: 'Boolean value', documentation: 'Boolean false' }, + ].forEach((option) => { + suggestions.push({ + label: option.label, + kind: languages.CompletionItemKind.Value, + insertText: option.insertText, + documentation: option.documentation, + detail: option.detail, + range: this.getRangeForWord(context), + }); + }); + } + + private addToValueCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + ['active', 'inactive', 'pending', 'true', 'false'].forEach((value) => { + suggestions.push({ + label: value, + kind: languages.CompletionItemKind.Value, + insertText: value, + documentation: `Common target value: ${value}`, + detail: 'Target value', + range: this.getRangeForWord(context), + }); + }); + } + + private addAssignmentValueCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + [ + { label: '$.', insertText: '$.', detail: 'Current captured value', documentation: 'Reference the current captured payload' }, + { label: '$previous.', insertText: '$previous.', detail: 'Previous captured value', documentation: 'Reference the previous state of the payload' }, + { label: '$context.occurred', insertText: '$context.occurred', detail: 'Occurred timestamp', documentation: 'When the capture source produced the current data' }, + { label: '$context.eventSourceId', insertText: '$context.eventSourceId', detail: 'Event source identifier', documentation: 'The resolved event source identifier for the append operation' }, + { label: '$env.', insertText: '$env.', detail: 'Environment variable', documentation: 'Read an environment variable' }, + ].forEach((option) => { + suggestions.push({ + label: option.label, + kind: languages.CompletionItemKind.Variable, + insertText: option.insertText, + documentation: option.documentation, + detail: option.detail, + range: this.getRangeForWord(context), + }); + }); + } + + private addPathCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, prefix: string): void { + commonPropertyPaths.forEach((path) => { + suggestions.push({ + label: path, + kind: languages.CompletionItemKind.Property, + insertText: `${prefix}${path}`, + documentation: `Common property path: ${prefix}${path}`, + detail: prefix === '$.' ? 'Current value path' : 'Previous value path', + range: this.getRangeForWord(context), + }); + }); + } + + private addContextCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + [ + { label: 'occurred', insertText: '$context.occurred', detail: 'When the event occurred', documentation: 'Timestamp for when the append event happened' }, + { label: 'eventSourceId', insertText: '$context.eventSourceId', detail: 'Event source identifier', documentation: 'The event source identifier used for the appended event' }, + ].forEach((option) => { + suggestions.push({ + label: option.label, + kind: languages.CompletionItemKind.Property, + insertText: option.insertText, + documentation: option.documentation, + detail: option.detail, + range: this.getRangeForWord(context), + }); + }); + } + + private addEnvironmentCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + suggestions.push({ + label: 'API_TOKEN', + kind: languages.CompletionItemKind.Variable, + insertText: '$env.API_TOKEN', + documentation: 'Placeholder environment variable reference', + detail: 'Environment variable', + range: this.getRangeForWord(context), + }); + } + + private getCurrentSourceType(model: editor.ITextModel, lineNumber: number): string | null { + for (let index = lineNumber; index >= 1; index--) { + const line = model.getLineContent(index).trim(); + const match = line.match(/^source\s+(api|webhook|message)\b/); + if (match) { + return match[1]; + } + } + return null; + } + + private getRangeForWord(context: CompletionContext): IRange { + return { + startLineNumber: context.lineNumber, + endLineNumber: context.lineNumber, + startColumn: context.wordStartColumn, + endColumn: context.wordEndColumn, + }; + } +} diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/HoverProvider.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/HoverProvider.ts new file mode 100644 index 0000000..1f2a33a --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/HoverProvider.ts @@ -0,0 +1,99 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { editor, languages, Position, IRange } from 'monaco-editor'; + +export class HoverProvider implements languages.HoverProvider { + provideHover(model: editor.ITextModel, position: Position): languages.ProviderResult { + const token = this.getTokenAtPosition(model, position); + if (!token) { + return null; + } + + const keywordInfo = this.getKeywordInfo(token.word); + if (keywordInfo) { + return { + contents: [{ value: keywordInfo }], + range: token.range, + }; + } + + const expressionInfo = this.getExpressionInfo(token.word); + if (expressionInfo) { + return { + contents: [{ value: expressionInfo }], + range: token.range, + }; + } + + return null; + } + + private getTokenAtPosition(model: editor.ITextModel, position: Position): { word: string; range: IRange } | null { + const lineContent = model.getLineContent(position.lineNumber); + const tokens = [...lineContent.matchAll(/\$context|\$previous|\$env|[a-zA-Z_][\w-]*/g)]; + const column = position.column - 1; + + for (const token of tokens) { + const startIndex = token.index ?? 0; + const endIndex = startIndex + token[0].length; + if (column >= startIndex && column <= endIndex) { + return { + word: token[0], + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: startIndex + 1, + endColumn: endIndex + 1, + }, + }; + } + } + + return null; + } + + // No `auth`/`bearer`/`url` — removed with the grammar. `api`, `route`, `path`, and + // `topic` reflect the current source-property meanings (see grammar.md). + private getKeywordInfo(word: string): string | null { + const keywords: Record = { + capture: '**capture** **\n\nDeclares a capture and gives it a unique name.', + source: '**source** **\n\nDefines where the capture reads input from.', + key: '**key** **\n\nSelects the property used to identify each captured item.', + map: '**map**\n\nMaps source fields into a normalized shape for later append blocks.', + append: '**append** **\n\nDeclares an event to append when the `when` clause matches.', + when: '**when** **\n\nSpecifies when an append block should fire.', + nested: '**nested** **\n\nCreates a nested mapping scope for an object property.', + children: '**children** ** **identified by** **\n\nCreates a scope for child collection items.', + identified: '**identified**\n\nUsed with `children` to declare how child items are identified.', + by: '**by**\n\nUsed in `children ... identified by`, `split ... by`, and similar clauses.', + api: '**api**\n\nAs a source type (`source api`): polls an HTTP API for changes.\n\nAs a source property (`api `): references a configured External Service by name for the base URL and authentication.', + webhook: '**webhook**\n\nA source type for receiving webhook payloads.', + message: '**message**\n\nA source type for consuming messages from a topic.', + route: '**route** **\n\nOptional path appended to an API source\'s External Service base URL. If omitted, the base URL is used as-is.', + poll: '**poll** **\n\nSets the polling interval for an API source.', + path: '**path** **\n\nThe inbound path a webhook source listens on.', + topic: '**topic** **\n\nConfigures the topic for a message source.', + from: '**from** **\n\nUsed in a `when` clause to match the previous value.', + to: '**to** **\n\nUsed in a `when` clause to match the new value.', + or: '**or**\n\nCombines multiple `when` conditions.', + and: '**and**\n\nCombines multiple `when` conditions that must all match.', + added: '**added**\n\nMatches when a child item was added.', + removed: '**removed**\n\nMatches when a child item was removed.', + translate: '**translate**\n\nMaps source values to new values inside a `map` block.', + split: '**split** ** **by** **\n\nSplits a string into multiple mapped fields.', + }; + + return keywords[word] || null; + } + + private getExpressionInfo(word: string): string | null { + const expressions: Record = { + '$context': '**$context**\n\nCapture execution context.\n\n**Properties:**\n- `occurred` - When the current event should be considered to have occurred\n- `eventSourceId` - The resolved event source identifier', + '$previous': '**$previous**\n\nThe previous state for the captured item. Use dot notation such as `$previous.status`.', + '$env': '**$env**\n\nEnvironment variables. Use dot notation such as `$env.API_TOKEN`.', + }; + + return expressions[word] || null; + } +} diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/Validator.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/Validator.ts new file mode 100644 index 0000000..3af44ac --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/Validator.ts @@ -0,0 +1,97 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ValidationIssue } from '../../validation'; + +interface MeaningfulLine { + line: string; + trimmed: string; + lineNumber: number; + indent: number; +} + +// Structural rules only, unaffected by the source-property grammar changes — the +// grammar's own syntax (see grammar.md) is what the tokenizer enforces; these are +// the compiler's semantic constraints on top of it. Takes plain lines rather than +// a Monaco model so it stays usable outside an editor, matching ../../validation.ts. +export class Validator { + validate(lines: string[]): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + let firstNonEmptyLineIndex = -1; + let firstLine = ''; + for (let index = 0; index < lines.length; index++) { + const trimmed = lines[index].trim(); + if (trimmed && !trimmed.startsWith('#')) { + firstNonEmptyLineIndex = index; + firstLine = trimmed; + break; + } + } + + if (!firstLine) { + return issues; + } + + const captureMatch = firstLine.match(/^capture\s+([\w.]+)\s*$/); + if (!captureMatch) { + issues.push(this.error(firstNonEmptyLineIndex, 1, firstLine.length + 1, 'Capture definition must start with "capture "')); + return issues; + } + + const meaningfulLines: MeaningfulLine[] = lines + .map((line, index) => ({ + line, + trimmed: line.trim(), + lineNumber: index, + indent: line.search(/\S/), + })) + .filter((entry) => entry.trimmed && !entry.trimmed.startsWith('#')); + + const hasSource = meaningfulLines.some((entry) => entry.trimmed.startsWith('source ')); + if (!hasSource) { + issues.push(this.error(firstNonEmptyLineIndex, 1, firstLine.length + 1, 'Capture definition must include a "source" block')); + } + + const hasKey = meaningfulLines.some((entry) => entry.trimmed.startsWith('key ')); + if (!hasKey) { + issues.push(this.error(firstNonEmptyLineIndex, 1, firstLine.length + 1, 'Capture definition must include a "key" declaration')); + } + + for (let index = 0; index < meaningfulLines.length; index++) { + const current = meaningfulLines[index]; + if (!current.trimmed.startsWith('append ')) { + continue; + } + + let hasWhenClause = false; + for (let next = index + 1; next < meaningfulLines.length; next++) { + const candidate = meaningfulLines[next]; + if (candidate.indent <= current.indent) { + break; + } + if (candidate.trimmed.startsWith('when ')) { + hasWhenClause = true; + break; + } + } + + if (!hasWhenClause) { + const eventName = current.trimmed.substring('append '.length).trim() || 'unnamed append block'; + const startColumn = current.line.indexOf(eventName) + 1; + issues.push(this.error( + current.lineNumber, + startColumn > 0 ? startColumn : 1, + startColumn > 0 ? startColumn + eventName.length : current.line.length + 1, + `Append block '${eventName}' must include a "when" clause`, + )); + } + } + + return issues; + } + + private error(line: number, startColumn: number, endColumn: number, message: string): ValidationIssue { + return { severity: 'error', line, startColumn, endColumn, message }; + } +} diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/index.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/index.ts new file mode 100644 index 0000000..6c1370f --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/index.ts @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { IDisposable, editor } from 'monaco-editor'; +import { Monaco, languageConfiguration, languageId, monarchLanguage } from './language'; +import { CompletionProvider } from './CompletionProvider'; +import { HoverProvider } from './HoverProvider'; +import { Validator } from './Validator'; + +const markerOwner = 'cdl-validator'; + +// Keyed by Monaco instance rather than a single boolean — mirrors the composite +// language's own registration guard (../../index.ts) and lets a second register() +// call for the same instance hand back the existing disposer instead of doubling +// up the completion/hover providers. +const disposersByMonaco = new Map void>(); + +function toMarkers(monaco: Monaco, validator: Validator, model: editor.ITextModel): editor.IMarkerData[] { + return validator.validate(model.getLinesContent()).map((issue) => ({ + severity: issue.severity === 'error' ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning, + message: issue.message, + startLineNumber: issue.line + 1, + startColumn: issue.startColumn, + endLineNumber: issue.line + 1, + endColumn: issue.endColumn, + })); +} + +export function registerCaptureLanguage(monaco: Monaco): { dispose(): void } { + const existing = disposersByMonaco.get(monaco); + if (existing) { + return { dispose: existing }; + } + + monaco.languages.register({ id: languageId }); + monaco.languages.setLanguageConfiguration(languageId, languageConfiguration); + monaco.languages.setMonarchTokensProvider(languageId, monarchLanguage); + + const validator = new Validator(); + const disposables: IDisposable[] = []; + + disposables.push(monaco.languages.registerCompletionItemProvider(languageId, new CompletionProvider())); + disposables.push(monaco.languages.registerHoverProvider(languageId, new HoverProvider())); + + const validateModel = (model: editor.ITextModel): void => { + if (model.isDisposed() || model.getLanguageId() !== languageId) { + return; + } + monaco.editor.setModelMarkers(model, markerOwner, toMarkers(monaco, validator, model)); + }; + + disposables.push(monaco.editor.onDidCreateModel((model) => { + if (model.getLanguageId() !== languageId) { + return; + } + validateModel(model); + disposables.push(model.onDidChangeContent(() => validateModel(model))); + })); + + monaco.editor.getModels().forEach((model) => validateModel(model)); + + const dispose = (): void => { + disposables.forEach((disposable) => disposable.dispose()); + disposables.length = 0; + disposersByMonaco.delete(monaco); + }; + + disposersByMonaco.set(monaco, dispose); + return { dispose }; +} + +export { languageId } from './language'; diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/language.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/language.ts new file mode 100644 index 0000000..9d3d038 --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/capture/language.ts @@ -0,0 +1,176 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { languages } from 'monaco-editor'; + +export type Monaco = typeof import('monaco-editor'); + +// Capture Declaration Language (CDL) — Chronicle's DSL for declaring change-data-capture +// rules, registered here as its own standalone top-level Monaco language. This is distinct +// from the lightweight CDL sub-language embedded inside a `.play` document's `capture` +// blocks (../cdl.ts) — this module owns its own language id, tokenizer, and providers. +export const languageId = 'cdl'; + +// `route`/`poll` are API source properties; `path` is the webhook inbound path; `topic` +// is the message source subject. There is no `auth`/`bearer`/`url` — an API source +// references a named External Service (`api `) for base URL and auth, and +// a webhook source's authentication is configured on the source builder, not in CDL text. +const KEYWORDS = [ + 'capture', + 'source', + 'key', + 'map', + 'append', + 'when', + 'nested', + 'children', + 'identified', + 'by', + 'api', + 'webhook', + 'message', + 'route', + 'poll', + 'path', + 'topic', + 'from', + 'to', + 'or', + 'and', + 'added', + 'removed', + 'translate', + 'split', +] as const; + +const BUILTINS = [ + '$context', + '$previous', + '$env', +] as const; + +const OPERATORS = ['=', '=>', '.']; + +export const languageConfiguration: languages.LanguageConfiguration = { + comments: { + lineComment: '#', + }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + { open: '`', close: '`' }, + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + { open: '`', close: '`' }, + ], + indentationRules: { + increaseIndentPattern: /^.*(\bcapture\b\s+\S+|\bsource\b(?:\s+\w+)?|\bmap\b|\bappend\b\s+\S+|\bnested\b\s+\S+|\bchildren\b\s+\S+.*|\btranslate\b|\bsplit\b.*)\s*$/, + decreaseIndentPattern: /^.*\}.*$/, + }, + folding: { + offSide: true, + markers: { + start: /^\s*(capture|source|map|append|nested|children|translate|split)/, + end: /^\s*$/, + }, + }, + wordPattern: /[$]?[a-zA-Z_][\w$-]*/, +}; + +export const monarchLanguage: languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.cdl', + + keywords: KEYWORDS, + builtins: BUILTINS, + operators: OPERATORS, + + symbols: /[=>/, 'operator.arrow'], + [ + /@symbols/, + { + cases: { + '@operators': 'operator', + '@default': '', + }, + }, + ], + [/\d*\.\d+([eE][-+]?\d+)?/, 'number.float'], + [/0[xX][0-9a-fA-F]+/, 'number.hex'], + [/\d+/, 'number'], + [/[;,.]/, 'delimiter'], + [/"([^"\\]|\\.)*$/, 'string.invalid'], + [/"/, { token: 'string.quote', next: '@string' }], + ], + + whitespace: [ + [/[ \t\r\n]+/, ''], + [/#.*$/, 'comment'], + ], + + templateString: [ + [/\$\{/, { token: 'delimiter.bracket', next: '@templateExpression' }], + [/[^\\`$]+/, 'string.template'], + [/@escapes/, 'string.template.escape'], + [/\\./, 'string.template.escape.invalid'], + [/`/, { token: 'string.template', next: '@pop' }], + ], + + templateExpression: [ + [/\}/, { token: 'delimiter.bracket', next: '@pop' }], + { include: '@root' }, + ], + + string: [ + [/[^\\"]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, { token: 'string.quote', next: '@pop' }], + ], + }, +}; diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/cdl.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/cdl.ts index 7a3cf69..64c736d 100644 --- a/Source/Screenplay/Monaco/screenplay-language/sub-languages/cdl.ts +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/cdl.ts @@ -3,11 +3,14 @@ import { SubLanguageDefinition } from '../sub-language-registry'; -// Change Data Capture Language (CDL) — embedded inside `capture` blocks. +// Change Data Capture Language (CDL) — embedded inside `capture` blocks. This is the +// lightweight definition mixed into the composite `.play` tokenizer; the full standalone +// CDL language (its own tokenizer, context-aware completions, hover, and validation) lives +// in `./capture` and is what `@cratis/screenplay-language/capture` exposes to consumers. export const cdl: SubLanguageDefinition = { tokens: [ [ - /\b(?:source|api|route|poll|key|map|translate|split|append|tag|when|children|nested|identified|by|from|to|and|or|added|removed|changed)\b/, + /\b(?:source|api|route|poll|path|topic|key|map|translate|split|append|tag|when|children|nested|identified|by|from|to|and|or|added|removed)\b/, 'keyword', ], ], @@ -55,9 +58,11 @@ export const cdl: SubLanguageDefinition = { ], hovers: { source: 'CDL — declares where the captured data comes from.', - api: 'CDL — the API used as the capture source.', - route: 'CDL — the route on the source API to poll.', - poll: 'CDL — the polling interval, e.g. 5m.', + api: 'CDL — as a source type, polls an HTTP API; as a source property (`api `), references a named External Service for the base URL and authentication.', + route: 'CDL — optional path appended to an API source\'s External Service base URL.', + poll: 'CDL — the polling interval for an API source, e.g. 5m.', + path: 'CDL — the inbound path a webhook source listens on.', + topic: 'CDL — the topic a message source subscribes to.', key: 'CDL — declares which source property identifies an instance.', map: 'CDL — maps and translates source values before events are appended.', translate: 'CDL — translates source values into Screenplay values.', diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/CodeActionProvider.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/CodeActionProvider.ts new file mode 100644 index 0000000..b912fd9 --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/CodeActionProvider.ts @@ -0,0 +1,123 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type * as Monaco from 'monaco-editor'; +import type { JsonSchema, ReadModelInfo, DraftReadModelInfo } from './index'; + +export class CodeActionProvider { + private readModels: ReadModelInfo[] = []; + private draftReadModel: DraftReadModelInfo | null = null; + private onCreateReadModel?: (readModelName: string) => void; + private onEditReadModel?: (readModelName: string, currentSchema: JsonSchema) => void; + + setReadModels(readModels: ReadModelInfo[]): void { + this.readModels = readModels; + } + + setDraftReadModel(draft: DraftReadModelInfo | null): void { + this.draftReadModel = draft; + } + + // Keep for backwards compatibility + setReadModelSchemas(schemas: JsonSchema[]): void { + this.readModels = schemas.map(schema => ({ + identifier: schema.title || schema.name || '', + displayName: schema.title || schema.name || '', + schema + })); + } + + setCreateReadModelCallback(callback: (readModelName: string) => void): void { + this.onCreateReadModel = callback; + } + + setEditReadModelCallback(callback: (readModelName: string, currentSchema: JsonSchema) => void): void { + this.onEditReadModel = callback; + } + + invokeCreateReadModel(readModelName: string): void { + this.onCreateReadModel?.(readModelName); + } + + invokeEditReadModel(readModelName: string, currentSchema: JsonSchema): void { + this.onEditReadModel?.(readModelName, currentSchema); + } + + provideCodeActions( + _model: Monaco.editor.ITextModel, + _range: Monaco.Range, + context: Monaco.languages.CodeActionContext + ): Monaco.languages.ProviderResult { + const actions: Monaco.languages.CodeAction[] = []; + + // Check for diagnostics about undefined read models (errors) + const notFoundDiagnostics = context.markers.filter( + (marker: Monaco.editor.IMarkerData) => marker.message.includes("Read model") && marker.message.includes("not found") + ); + + // Check for diagnostics about draft read models (info markers) + const draftDiagnostics = context.markers.filter( + (marker: Monaco.editor.IMarkerData) => marker.message.includes("Read model") && marker.message.includes("is a draft") + ); + + // Handle "not found" errors - offer to create + for (const diagnostic of notFoundDiagnostics) { + const match = diagnostic.message.match(/Read model ['"]([\w.]+)['"]/); + if (match && match[1]) { + const readModelName = match[1]; + + // Check if this read model already exists as a persisted read model + const existsAsPersisted = this.readModels.some( + rm => rm.identifier === readModelName || rm.displayName === readModelName + ); + + if (!existsAsPersisted && this.onCreateReadModel) { + actions.push({ + title: `Create read model '${readModelName}'`, + diagnostics: [diagnostic], + kind: 'quickfix', + edit: undefined, + isPreferred: true, + command: { + id: 'pdl.createReadModel', + title: `Create ${readModelName}`, + arguments: [readModelName] + } + }); + } + } + } + + // Handle draft read model info markers - offer to edit + for (const diagnostic of draftDiagnostics) { + const match = diagnostic.message.match(/Read model ['"]([\w.]+)['"]/); + if (match && match[1]) { + const readModelName = match[1]; + + if (this.draftReadModel && this.isDraftReadModel(readModelName) && this.onEditReadModel) { + actions.push({ + title: `Edit read model '${readModelName}'`, + diagnostics: [diagnostic], + kind: 'quickfix', + edit: undefined, + isPreferred: true, + command: { + id: 'pdl.editReadModel', + title: `Edit ${readModelName}`, + arguments: [readModelName, this.draftReadModel.schema] + } + }); + } + } + } + + return { + actions, + dispose: () => { /* No cleanup needed */ } + }; + } + + private isDraftReadModel(readModelToken: string): boolean { + return !!this.draftReadModel && (this.draftReadModel.identifier === readModelToken || this.draftReadModel.displayName === readModelToken); + } +} diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/CompletionProvider.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/CompletionProvider.ts new file mode 100644 index 0000000..a0760f4 --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/CompletionProvider.ts @@ -0,0 +1,653 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { editor, languages, IRange, Position } from 'monaco-editor'; +import type { JsonSchema, JsonSchemaProperty, ReadModelInfo } from './index'; + +interface CompletionContext { + lineContent: string; + textBeforeCursor: string; + textAfterCursor: string; + currentWord: string; + indentLevel: number; + lineNumber: number; +} + +export class CompletionProvider implements languages.CompletionItemProvider { + private readModels: ReadModelInfo[] = []; + private eventSchemas: Record = {}; + private eventSequences: string[] = []; + + setReadModels(readModels: ReadModelInfo[]): void { + this.readModels = readModels || []; + } + + setEventSequences(sequences: string[]): void { + this.eventSequences = sequences || []; + } + + // Keep for backwards compatibility + setReadModelSchemas(schemas: JsonSchema[]): void { + this.readModels = schemas.map(schema => ({ + identifier: this.getSchemaName(schema) || '', + displayName: this.getSchemaName(schema) || '', + schema + })); + } + + setEventSchemas(schemas: Record | JsonSchema[]): void { + if (!schemas) { + this.eventSchemas = {}; + return; + } + + if (Array.isArray(schemas)) { + const map: Record = {}; + schemas.forEach((s, i) => { + if (!s) return; + const name = this.getSchemaName(s) || `Event${i + 1}`; + map[name] = s; + }); + this.eventSchemas = map; + return; + } + + this.eventSchemas = schemas; + } + + provideCompletionItems( + model: editor.ITextModel, + position: Position + ): languages.ProviderResult { + const context = this.buildContext(model, position); + const suggestions: languages.CompletionItem[] = []; + + // Get the active read model schema from the projection declaration + const activeSchema = this.getActiveReadModelSchema(model); + + // Determine what kind of completions to provide based on context + if (context.lineNumber === 1) { + this.addProjectionLineCompletions(suggestions, context); + } else if (this.isAfterKeyword(context, 'sequence')) { + this.addEventSequenceCompletions(suggestions, context); + } else if (this.isAfterKeyword(context, 'from') || this.isAfterKeyword(context, 'every')) { + this.addEventTypeCompletions(suggestions, context); + } else if (this.isAfterKeyword(context, 'key')) { + this.addKeyCompletions(suggestions, context, activeSchema); + } else if (this.isAfterKeyword(context, 'parent')) { + this.addParentCompletions(suggestions, context); + } else if (this.isAfterKeyword(context, 'children')) { + this.addChildrenCompletions(suggestions, context, activeSchema); + } else if (this.isAfterKeyword(context, 'join')) { + this.addJoinCompletions(suggestions, context, activeSchema); + } else if (this.isAfterKeyword(context, 'with')) { + this.addEventTypeCompletions(suggestions, context); + } else if (this.isAfterNumericOperationKeyword(context)) { + this.addNumericPropertyCompletions(suggestions, context, activeSchema); + } else if (this.isAfterAddOrSubtractBy(context)) { + this.addAssignmentValueCompletions(suggestions, context, model, position); + } else if (this.isInAssignment(context)) { + this.addAssignmentValueCompletions(suggestions, context, model, position); + } else if (this.isPropertyDotAccess(context)) { + this.addPropertyMemberCompletions(suggestions, context); + } else if (this.isStartOfLine(context) || this.isAfterIndent(context)) { + this.addStatementCompletions(suggestions, context, activeSchema); + } + + return { suggestions }; + } + + private buildContext(model: editor.ITextModel, position: Position): CompletionContext { + const lineContent = model.getLineContent(position.lineNumber); + const textBeforeCursor = lineContent.substring(0, position.column - 1); + const textAfterCursor = lineContent.substring(position.column - 1); + const word = model.getWordAtPosition(position); + const currentWord = word?.word || ''; + const indentLevel = this.getIndentLevel(textBeforeCursor); + + return { + lineContent, + textBeforeCursor, + textAfterCursor, + currentWord, + indentLevel, + lineNumber: position.lineNumber, + }; + } + + private getIndentLevel(text: string): number { + const match = text.match(/^(\s*)/); + return match ? Math.floor(match[1].length / 2) : 0; + } + + private isStartOfLine(context: CompletionContext): boolean { + return context.textBeforeCursor.trim() === ''; + } + + private isAfterIndent(context: CompletionContext): boolean { + return /^\s+$/.test(context.textBeforeCursor); + } + + private isAfterKeyword(context: CompletionContext, keyword: string): boolean { + const pattern = new RegExp(`\\b${keyword}\\s+\\w*$`); + return pattern.test(context.textBeforeCursor); + } + + private isAfterNumericOperationKeyword(context: CompletionContext): boolean { + // Match: count, increment, decrement, add, subtract followed by optional word (but not "by") + // For add/subtract, we only match if "by" hasn't been typed yet + const countIncrDecrPattern = /\b(count|increment|decrement)\s+\w*$/; + const addSubtractPattern = /\b(add|subtract)\s+\w*$/; + + // Check for count/increment/decrement first + if (countIncrDecrPattern.test(context.textBeforeCursor)) { + return true; + } + + // For add/subtract, only match if "by" hasn't been typed yet + if (addSubtractPattern.test(context.textBeforeCursor)) { + // Make sure we're not after "by" + return !this.isAfterAddOrSubtractBy(context); + } + + return false; + } + + private isAfterAddOrSubtractBy(context: CompletionContext): boolean { + // Match: "add by " or "subtract by " followed by optional word + const pattern = /\b(add|subtract)\s+\w+\s+by\s+\w*$/; + return pattern.test(context.textBeforeCursor); + } + + private isInAssignment(context: CompletionContext): boolean { + return /\w+\s*=\s*\w*$/.test(context.textBeforeCursor); + } + + private isPropertyDotAccess(context: CompletionContext): boolean { + return /(\w+|\$\w+)\.(\w*)$/.test(context.textBeforeCursor); + } + + private addProjectionLineCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + const trimmed = context.textBeforeCursor.trim(); + + // Suggest "projection" keyword + if (trimmed === '' || 'projection'.startsWith(trimmed)) { + suggestions.push({ + label: 'projection', + kind: 14, // Keyword + insertText: 'projection ', + documentation: 'Define a projection that transforms events into a read model', + detail: 'projection ', + range: this.getRangeForWord(context), + }); + } + + // Check if cursor is after "=>" to suggest read model names + const arrowMatch = trimmed.match(/^projection\s+[\w.]+\s*=>\s*([\w.]*)$/); + if (arrowMatch) { + const partialReadModel = arrowMatch[1] || ''; + this.readModels.forEach((readModel) => { + if (!partialReadModel || readModel.displayName.toLowerCase().startsWith(partialReadModel.toLowerCase())) { + const label = `${readModel.displayName} (${readModel.identifier})`; + suggestions.push({ + label, + kind: 6, // Class + insertText: readModel.displayName, + filterText: `${readModel.displayName} ${readModel.identifier}`, + documentation: `Read model: ${readModel.displayName} (${readModel.identifier})`, + detail: readModel.identifier, + range: this.getRangeForWord(context), + }); + } + }); + return; + } + + // After "projection ", suggest read model names (for quick completion of both projection name and read model) + if (trimmed.startsWith('projection ') && !trimmed.includes('=>')) { + const afterProjection = trimmed.substring('projection '.length); + this.readModels.forEach((readModel) => { + if (!afterProjection || readModel.displayName.toLowerCase().startsWith(afterProjection.toLowerCase())) { + const label = `${readModel.displayName} (${readModel.identifier})`; + suggestions.push({ + label, + kind: 6, // Class + insertText: `${readModel.displayName} => ${readModel.displayName}`, + filterText: `${readModel.displayName} ${readModel.identifier}`, + documentation: `Read model: ${readModel.displayName} (${readModel.identifier})`, + detail: readModel.identifier, + range: this.getRangeForWord(context), + }); + } + }); + } + } + + private addEventTypeCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + Object.keys(this.eventSchemas).forEach((eventName) => { + if (!context.currentWord || eventName.startsWith(context.currentWord)) { + const schema = this.eventSchemas[eventName]; + suggestions.push({ + label: eventName, + kind: 6, // Class + insertText: eventName, + documentation: this.getSchemaDescription(schema) || `Event type: ${eventName}`, + detail: 'Event Type', + range: this.getRangeForWord(context), + }); + } + }); + } + + private addEventSequenceCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + this.eventSequences.forEach((sequenceId) => { + if (!context.currentWord || sequenceId.startsWith(context.currentWord)) { + suggestions.push({ + label: sequenceId, + kind: 13, // Value + insertText: sequenceId, + documentation: `Event sequence: ${sequenceId}`, + detail: 'Event Sequence', + range: this.getRangeForWord(context), + }); + } + }); + } + + private addKeyCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void { + // Check if this might be a composite key (type reference) + if (activeSchema?.definitions) { + Object.keys(activeSchema.definitions).forEach((typeName) => { + const typeDef = activeSchema.definitions![typeName]; + if (typeDef.type === 'object') { + suggestions.push({ + label: typeName, + kind: 6, // Class + insertText: typeName, + documentation: `Composite key type: ${typeName}`, + detail: 'Composite Key Type', + range: this.getRangeForWord(context), + }); + } + }); + } + + // Suggest built-in expressions for simple keys + this.addExpressionCompletions(suggestions, context); + } + + private addParentCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + this.addExpressionCompletions(suggestions, context); + } + + private addNumericPropertyCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void { + // After "count ", "increment ", or "decrement ", suggest numeric properties from read model + if (activeSchema?.properties) { + Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => { + // Suggest numeric properties (integer or number types) + const isNumeric = propSchema.type === 'integer' || propSchema.type === 'number'; + if (isNumeric) { + if (!context.currentWord || propName.startsWith(context.currentWord)) { + suggestions.push({ + label: propName, + kind: 9, // Property + insertText: propName, + documentation: `Numeric property: ${propName}`, + detail: this.getPropertyTypeDescription(propSchema), + range: this.getRangeForWord(context), + }); + } + } + }); + } + + // Also check if "add" or "subtract" is being typed - need to handle "add by" pattern + const addSubtractMatch = context.textBeforeCursor.match(/\b(add|subtract)\s+(\w*)$/); + if (addSubtractMatch) { + const partialProp = addSubtractMatch[2] || ''; + if (activeSchema?.properties) { + Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => { + const isNumeric = propSchema.type === 'integer' || propSchema.type === 'number'; + if (isNumeric && (!partialProp || propName.startsWith(partialProp))) { + suggestions.push({ + label: propName, + kind: 9, // Property + insertText: `${propName} by `, + documentation: `Numeric property: ${propName}`, + detail: this.getPropertyTypeDescription(propSchema), + range: this.getRangeForWord(context), + }); + } + }); + } + } + } + + private addChildrenCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void { + // After "children ", suggest collection properties from read model + if (activeSchema?.properties) { + Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => { + // Collections are typically arrays + if (propSchema.type === 'array' || propSchema.items) { + suggestions.push({ + label: propName, + kind: 9, // Property + insertText: `${propName} id `, + documentation: `Collection property: ${propName}`, + detail: this.getPropertyTypeDescription(propSchema), + range: this.getRangeForWord(context), + }); + } + }); + } + } + + private addJoinCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void { + const trimmed = context.textBeforeCursor.trim(); + + if (trimmed === 'join' || !trimmed.includes(' ')) { + // Suggest collection properties + if (activeSchema?.properties) { + Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => { + if (propSchema.type === 'array' || propSchema.items) { + suggestions.push({ + label: propName, + kind: 9, + insertText: `${propName} on `, + documentation: `Join on collection: ${propName}`, + range: this.getRangeForWord(context), + }); + } + }); + } + } else if (trimmed.match(/join\s+\w+\s+on\s+/)) { + // After "on", suggest properties to join on + if (activeSchema?.properties) { + Object.keys(activeSchema.properties).forEach((propName) => { + suggestions.push({ + label: propName, + kind: 9, + insertText: propName, + documentation: `Property: ${propName}`, + range: this.getRangeForWord(context), + }); + }); + } + } + } + + private addStatementCompletions(suggestions: languages.CompletionItem[], context: CompletionContext, activeSchema?: JsonSchema): void { + // Add all statement keywords + const keywords = [ + { label: 'sequence', insertText: 'sequence ', documentation: 'Specify which event sequence to use for this projection', detail: 'sequence ' }, + { label: 'from', insertText: 'from ', documentation: 'Handle specific event types', detail: 'from ' }, + { 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' }, + { label: 'every', insertText: 'every', documentation: 'Handle all events for automatic mapping (only events explicitly subscribed to via from blocks)', detail: 'every' }, + { label: 'key', insertText: 'key ', documentation: 'Define the projection key', detail: 'key ' }, + { label: 'parent', insertText: 'parent ', documentation: 'Define parent relationship', detail: 'parent ' }, + { label: 'join', insertText: 'join ', documentation: 'Join with child collection', detail: 'join on ' }, + { label: 'children', insertText: 'children ', documentation: 'Define child collection operations', detail: 'children identified by ' }, + { label: 'remove with', insertText: 'remove with ', documentation: 'Remove items based on event', detail: 'remove with ' }, + { label: 'remove via', insertText: 'remove via join on ', documentation: 'Remove items via join', detail: 'remove via join on ' }, + { label: 'no automap', insertText: 'no automap', documentation: 'Disable automatic property mapping', detail: 'no automap' }, + { label: 'increment', insertText: 'increment ', documentation: 'Increment a numeric property', detail: 'increment ' }, + { label: 'decrement', insertText: 'decrement ', documentation: 'Decrement a numeric property', detail: 'decrement ' }, + { label: 'count', insertText: 'count ', documentation: 'Count occurrences', detail: 'count ' }, + { label: 'add', insertText: 'add ', documentation: 'Add value to property', detail: 'add by ' }, + { label: 'subtract', insertText: 'subtract ', documentation: 'Subtract value from property', detail: 'subtract by ' }, + { label: 'exclude children', insertText: 'exclude children', documentation: 'Exclude child collections from automap', detail: 'exclude children' }, + ]; + + keywords.forEach((kw) => { + if (!context.currentWord || kw.label.startsWith(context.currentWord)) { + suggestions.push({ + label: kw.label, + kind: 14, // Keyword + insertText: kw.insertText, + documentation: kw.documentation, + detail: kw.detail, + range: this.getRangeForWord(context), + }); + } + }); + + // Add property assignments + if (activeSchema?.properties) { + Object.entries(activeSchema.properties).forEach(([propName, propSchema]) => { + if (!context.currentWord || propName.startsWith(context.currentWord)) { + suggestions.push({ + label: propName, + kind: 9, // Property + insertText: `${propName} = `, + documentation: `Assign value to property: ${propName}`, + detail: this.getPropertyTypeDescription(propSchema), + range: this.getRangeForWord(context), + }); + } + }); + } + } + + private addAssignmentValueCompletions( + suggestions: languages.CompletionItem[], + context: CompletionContext, + model: editor.ITextModel, + position: Position + ): void { + // Get the event type from the current block + const eventType = this.getCurrentEventType(model, position.lineNumber); + + // Suggest event properties if we know the event type + if (eventType && this.eventSchemas[eventType]) { + const eventSchema = this.eventSchemas[eventType]; + if (eventSchema.properties) { + Object.entries(eventSchema.properties).forEach(([propName, propSchema]) => { + suggestions.push({ + label: propName, + kind: 9, + insertText: propName, + documentation: `Event property: ${propName}`, + detail: this.getPropertyTypeDescription(propSchema), + range: this.getRangeForWord(context), + }); + }); + } + } + + // Add expression completions + this.addExpressionCompletions(suggestions, context); + } + + private addExpressionCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + const expressions = [ + { + label: '$eventSourceId', + insertText: '$eventSourceId', + documentation: 'The identifier of the event source (aggregate/entity)', + detail: 'Event Source Expression' + }, + { + label: '$causedBy', + insertText: '$causedBy', + documentation: 'Information about who/what caused the event', + detail: 'Identity Expression', + children: [ + { name: 'subject', doc: 'The subject identifier' }, + { name: 'name', doc: 'The display name' }, + { name: 'userName', doc: 'The username' }, + ] + }, + { + label: '$eventContext', + insertText: '$eventContext.', + documentation: 'Access event context properties', + detail: 'Event Context Expression', + children: [ + { name: 'sequenceNumber', doc: 'The event sequence number' }, + { name: 'occurred', doc: 'When the event occurred' }, + { name: 'eventSourceId', doc: 'The event source identifier' }, + { name: 'namespace', doc: 'The event namespace' }, + ] + }, + { + label: '$occurred', + insertText: '$occurred', + documentation: 'Timestamp when the event occurred', + detail: 'Occurred Expression' + }, + { + label: '$namespace', + insertText: '$namespace', + documentation: 'The namespace of the event', + detail: 'Namespace Expression' + }, + ]; + + expressions.forEach((expr) => { + if (!context.currentWord || expr.label.startsWith(context.currentWord) || expr.label.startsWith('$' + context.currentWord)) { + suggestions.push({ + label: expr.label, + kind: 14, // Keyword/builtin + insertText: expr.insertText, + documentation: expr.documentation, + detail: expr.detail, + range: this.getRangeForWord(context), + }); + } + }); + } + + private addPropertyMemberCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void { + const match = context.textBeforeCursor.match(/(\w+|\$\w+)\.(\w*)$/); + if (!match) return; + + const [, objectName, partialProp] = match; + + // Handle $causedBy properties + if (objectName === '$causedBy') { + const causedByProps = [ + { name: 'subject', doc: 'The subject identifier', type: 'string' }, + { name: 'name', doc: 'The display name', type: 'string' }, + { name: 'userName', doc: 'The username', type: 'string' }, + ]; + + causedByProps.forEach((prop) => { + if (!partialProp || prop.name.startsWith(partialProp)) { + suggestions.push({ + label: prop.name, + kind: 9, + insertText: prop.name, + documentation: prop.doc, + detail: prop.type, + range: this.getRangeForWord(context), + }); + } + }); + return; + } + + // Handle $eventContext properties + if (objectName === '$eventContext') { + const contextProps = [ + { name: 'sequenceNumber', doc: 'The event sequence number', type: 'EventSequenceNumber' }, + { name: 'occurred', doc: 'When the event occurred', type: 'DateTimeOffset' }, + { name: 'eventSourceId', doc: 'The event source identifier', type: 'string' }, + { name: 'namespace', doc: 'The event namespace', type: 'string' }, + ]; + + contextProps.forEach((prop) => { + if (!partialProp || prop.name.startsWith(partialProp)) { + suggestions.push({ + label: prop.name, + kind: 9, + insertText: prop.name, + documentation: prop.doc, + detail: prop.type, + range: this.getRangeForWord(context), + }); + } + }); + return; + } + + // Handle event type properties + const eventSchema = this.eventSchemas[objectName]; + if (eventSchema?.properties) { + Object.entries(eventSchema.properties).forEach(([propName, propSchema]) => { + if (!partialProp || propName.startsWith(partialProp)) { + suggestions.push({ + label: propName, + kind: 9, + insertText: propName, + documentation: `Event property: ${propName}`, + detail: this.getPropertyTypeDescription(propSchema), + range: this.getRangeForWord(context), + }); + } + }); + } + } + + private getCurrentEventType(model: editor.ITextModel, lineNumber: number): string | null { + // Search backwards to find the event type declaration + for (let i = lineNumber; i >= 1; i--) { + const line = model.getLineContent(i).trim(); + const fromMatch = line.match(/^from\s+([\w-]+)/); + if (fromMatch) { + return fromMatch[1]; + } + const everyMatch = line.match(/^every$/); + if (everyMatch) { + return null; // every block doesn't have a specific event type + } + } + return null; + } + + private getActiveReadModelSchema(model: editor.ITextModel): JsonSchema | undefined { + const firstLine = model.getLineContent(1).trim(); + const match = firstLine.match(/^projection\s+[\w.]+\s*=>\s*([\w.]+)/); + if (!match) return undefined; + + const readModelName = match[1]; + const readModel = this.resolveReadModelInfo(readModelName); + return readModel?.schema; + } + + private resolveReadModelInfo(readModelToken: string): ReadModelInfo | null { + return this.readModels.find(rm => rm.identifier === readModelToken || rm.displayName === readModelToken) || null; + } + + private getSchemaName(schema: JsonSchema): string | undefined { + if (schema.name) return schema.name; + if (schema.title) return schema.title; + if (typeof schema.$id === 'string') { + const parts = schema.$id.split('/'); + return parts[parts.length - 1] || schema.$id; + } + return undefined; + } + + private getSchemaDescription(schema: JsonSchema): string | undefined { + return schema.description || undefined; + } + + private getPropertyTypeDescription(propSchema: JsonSchemaProperty): string { + if (propSchema.type === 'array' && propSchema.items) { + const itemType = propSchema.items.type || propSchema.items.$id?.split('/').pop() || 'unknown'; + return `${itemType}[]`; + } + return propSchema.type || propSchema.$ref?.split('/').pop() || 'unknown'; + } + + private getRangeForWord(context: CompletionContext): IRange { + const startColumn = context.textBeforeCursor.length - context.currentWord.length + 1; + const endColumn = startColumn + context.currentWord.length; + return { + startLineNumber: context.lineNumber, + startColumn: Math.max(1, startColumn), + endLineNumber: context.lineNumber, + endColumn: Math.max(1, endColumn), + }; + } +} diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/HoverProvider.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/HoverProvider.ts new file mode 100644 index 0000000..71b2e25 --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/HoverProvider.ts @@ -0,0 +1,338 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { editor, languages, Position } from 'monaco-editor'; +import type { JsonSchema, JsonSchemaProperty, ReadModelInfo, DraftReadModelInfo } from './index'; + +export class HoverProvider implements languages.HoverProvider { + private readModels: ReadModelInfo[] = []; + private eventSchemas: Record = {}; + private draftReadModel: DraftReadModelInfo | null = null; + + setReadModels(readModels: ReadModelInfo[]): void { + this.readModels = readModels || []; + } + + // Keep for backwards compatibility + setReadModelSchemas(schemas: JsonSchema[]): void { + this.readModels = schemas.map(schema => ({ + identifier: this.getSchemaName(schema) || '', + displayName: this.getSchemaName(schema) || '', + schema + })); + } + + setEventSchemas(schemas: Record | JsonSchema[]): void { + if (!schemas) { + this.eventSchemas = {}; + return; + } + + if (Array.isArray(schemas)) { + const map: Record = {}; + schemas.forEach((s, i) => { + if (!s) return; + const name = this.getSchemaName(s) || `Event${i + 1}`; + map[name] = s; + }); + this.eventSchemas = map; + return; + } + + this.eventSchemas = schemas; + } + + setDraftReadModel(draft: DraftReadModelInfo | null): void { + this.draftReadModel = draft; + } + + provideHover(model: editor.ITextModel, position: Position): languages.ProviderResult { + const word = model.getWordAtPosition(position); + if (!word) return null; + + const wordText = word.word; + + // Check if we're on the projection name (before '=>') + const lineContent = model.getLineContent(position.lineNumber); + const projectionNameMatch = this.getProjectionNameAtPosition(lineContent, position.column); + if (projectionNameMatch) { + return { + contents: [{ value: 'The name of the projection' }], + range: { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }, + }; + } + + // Check if it's a keyword + const keywordInfo = this.getKeywordInfo(wordText); + if (keywordInfo) { + return { + contents: [{ value: keywordInfo }], + range: { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }, + }; + } + + // Check if it's a built-in expression + const expressionInfo = this.getExpressionInfo(wordText); + if (expressionInfo) { + return { + contents: [{ value: expressionInfo }], + range: { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }, + }; + } + + // Check if it's an event type + if (this.eventSchemas[wordText]) { + const schema = this.eventSchemas[wordText]; + const description = this.getSchemaDescription(schema); + const properties = this.getSchemaProperties(schema); + + let content = `**Event Type:** \`${wordText}\`\n\n`; + if (description) { + content += `${description}\n\n`; + } + if (properties.length > 0) { + content += `**Properties:**\n\n${properties.map(p => `- \`${p.name}\`: ${p.type}`).join('\n')}`; + } + + return { + contents: [{ value: content }], + range: { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }, + }; + } + + // Check if it's a draft read model + if (this.isDraftReadModel(wordText)) { + const description = this.getSchemaDescription(this.draftReadModel!.schema); + const properties = this.getSchemaProperties(this.draftReadModel!.schema); + const identifier = this.draftReadModel!.identifier; + const displayName = this.draftReadModel!.displayName; + + let content = `**Read Model (Draft):** \`${displayName}\`\n\n**Identifier:** \`${identifier}\`\n\n`; + if (description) { + content += `${description}\n\n`; + } + if (properties.length > 0) { + content += `**Properties:**\n\n${properties.map(p => `- \`${p.name}\`: ${p.type}`).join('\n')}`; + } + + return { + contents: [{ value: content }], + range: { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }, + }; + } + + // Check if it's a read model + const readModel = this.resolveReadModelInfo(wordText); + if (readModel) { + const description = this.getSchemaDescription(readModel.schema); + const properties = this.getSchemaProperties(readModel.schema); + + let content = `**Read Model:** \`${readModel.displayName}\`\n\n**Identifier:** \`${readModel.identifier}\`\n\n`; + if (description) { + content += `${description}\n\n`; + } + if (properties.length > 0) { + content += `**Properties:**\n\n${properties.map(p => `- \`${p.name}\`: ${p.type}`).join('\n')}`; + } + + return { + contents: [{ value: content }], + range: { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }, + }; + } + + // Check if it's a property in the current context + const propertyInfo = this.getPropertyInfo(model, position, wordText); + if (propertyInfo) { + return { + contents: [{ value: propertyInfo }], + range: { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }, + }; + } + + return null; + } + + private getKeywordInfo(word: string): string | null { + const keywords: Record = { + 'projection': '**projection** - Defines a projection that transforms events into a read model', + 'from': '**from** **\n\nHandles specific event types and maps their properties to the read model.', + 'all': '**all**\n\nSubscribes to **all event types in the system**, regardless of explicit `from` blocks. Mappings inside `all` run for every event that arrives system-wide.', + 'every': '**every**\n\nHandles all events the projection **explicitly subscribes to** through `from` blocks, with automatic property mapping.', + 'key': '**key** **\n\nDefines the projection key or composite key type.', + 'parent': '**parent** **\n\nDefines a parent relationship for hierarchical projections.', + 'join': '**join** ** **on** **\n\nJoins with a child collection based on a property.', + 'children': '**children** ** **id** **\n\nDefines operations on child collections.', + 'remove': '**remove with** ** or **remove via join on** **\n\nRemoves items from the read model based on events.', + 'automap': '**automap** or **no automap**\n\nControls automatic property mapping from events to the read model.', + 'increment': '**increment** **\n\nIncrements a numeric property by 1.', + 'decrement': '**decrement** **\n\nDecrements a numeric property by 1.', + 'count': '**count** **\n\nCounts occurrences and stores in a property.', + 'add': '**add** ** **by** **\n\nAdds a value to a property.', + 'subtract': '**subtract** ** **by** **\n\nSubtracts a value from a property.', + 'exclude': '**exclude children**\n\nExcludes child collections from automatic mapping.', + 'events': '**events** **, ...\n\nSpecifies which event types to include in a join.', + 'via': 'Used with **remove via join on** to remove items via a join relationship.', + 'with': 'Used with **remove with** to specify which event type triggers removal.', + 'on': 'Used with **join** to specify the join property.', + 'by': 'Used with **add** or **subtract** to specify the value.', + 'id': 'Used with **children** to specify the child identifier expression.', + 'no': 'Used with **no automap** to disable automatic property mapping.', + }; + + return keywords[word] || null; + } + + private getExpressionInfo(word: string): string | null { + const expressions: Record = { + '$eventSourceId': '**$eventSourceId**\n\nThe identifier of the event source (aggregate/entity) that generated the event.', + '$causedBy': '**$causedBy**\n\nInformation about who/what caused the event.\n\n**Properties:**\n- `subject` - The subject identifier\n- `name` - The display name\n- `userName` - The username', + '$eventContext': '**$eventContext**\n\nAccess to event context properties.\n\n**Properties:**\n- `sequenceNumber` - The event sequence number\n- `occurred` - When the event occurred\n- `eventSourceId` - The event source identifier\n- `namespace` - The event namespace', + '$occurred': '**$occurred**\n\nThe timestamp when the event occurred.', + '$namespace': '**$namespace**\n\nThe namespace of the event.', + }; + + return expressions[word] || null; + } + + private getPropertyInfo(model: editor.ITextModel, position: Position, propertyName: string): string | null { + // Try to find the active read model + const firstLine = model.getLineContent(1).trim(); + const match = firstLine.match(/^projection\s+[\w.]+\s*=>\s*([\w.]+)/); + if (!match) return null; + + const readModelName = match[1]; + const draftMatch = this.isDraftReadModel(readModelName) ? this.draftReadModel : null; + const resolvedReadModel = draftMatch ? null : this.resolveReadModelInfo(readModelName); + const activeSchema = draftMatch?.schema || resolvedReadModel?.schema; + + if (activeSchema?.properties && activeSchema.properties[propertyName]) { + const prop = activeSchema.properties[propertyName]; + const type = this.getPropertyTypeDescription(prop); + const description = prop.description; + + let content = `**Property:** \`${propertyName}\`\n\n**Type:** \`${type}\``; + if (description) { + content += `\n\n${description}`; + } + return content; + } + + // Check if it's an event property + const eventType = this.getCurrentEventType(model, position.lineNumber); + if (eventType && this.eventSchemas[eventType]) { + const eventSchema = this.eventSchemas[eventType]; + if (eventSchema.properties && eventSchema.properties[propertyName]) { + const prop = eventSchema.properties[propertyName]; + const type = this.getPropertyTypeDescription(prop); + const description = prop.description; + + let content = `**Event Property:** \`${propertyName}\`\n\n**Type:** \`${type}\``; + if (description) { + content += `\n\n${description}`; + } + return content; + } + } + + return null; + } + + private resolveReadModelInfo(readModelToken: string): ReadModelInfo | null { + return this.readModels.find(rm => rm.identifier === readModelToken || rm.displayName === readModelToken) || null; + } + + private isDraftReadModel(readModelToken: string): boolean { + return !!this.draftReadModel && (this.draftReadModel.identifier === readModelToken || this.draftReadModel.displayName === readModelToken); + } + + private getCurrentEventType(model: editor.ITextModel, lineNumber: number): string | null { + for (let i = lineNumber; i >= 1; i--) { + const line = model.getLineContent(i).trim(); + const fromMatch = line.match(/^from\s+([\w-]+)/); + if (fromMatch) { + return fromMatch[1]; + } + } + return null; + } + + private getProjectionNameAtPosition(lineContent: string, column: number): boolean { + // Check if we're on line 1 (projection declaration line) + const projectionMatch = lineContent.match(/^\s*projection\s+(\S+)\s*=>/); + if (!projectionMatch) return false; + + const projectionKeywordEnd = lineContent.indexOf('projection') + 'projection'.length; + const arrowStart = lineContent.indexOf('=>'); + + // Check if cursor is between 'projection' keyword and '=>' + // and is on the projection name itself + return column > projectionKeywordEnd && column <= arrowStart; + } + + private getSchemaName(schema: JsonSchema): string | undefined { + if (schema.name) return schema.name; + if (schema.title) return schema.title; + if (typeof schema.$id === 'string') { + const parts = schema.$id.split('/'); + return parts[parts.length - 1] || schema.$id; + } + return undefined; + } + + private getSchemaDescription(schema: JsonSchema): string | undefined { + return schema.description || undefined; + } + + private getSchemaProperties(schema: JsonSchema): Array<{ name: string; type: string }> { + if (!schema.properties) return []; + + return Object.entries(schema.properties).map(([name, prop]) => ({ + name, + type: this.getPropertyTypeDescription(prop), + })); + } + + private getPropertyTypeDescription(propSchema: JsonSchemaProperty): string { + if (propSchema.type === 'array' && propSchema.items) { + const itemType = propSchema.items.type || propSchema.items.$ref?.split('/').pop() || 'unknown'; + return `${itemType}[]`; + } + return propSchema.type || propSchema.$ref?.split('/').pop() || 'unknown'; + } +} diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/Validator.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/Validator.ts new file mode 100644 index 0000000..ac37c6f --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/Validator.ts @@ -0,0 +1,387 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { editor } from 'monaco-editor'; +import type { JsonSchema, ReadModelInfo, DraftReadModelInfo } from './index'; + +export class Validator { + private readModels: ReadModelInfo[] = []; + private eventSchemas: Record = {}; + private draftReadModel: DraftReadModelInfo | null = null; + + setReadModels(readModels: ReadModelInfo[]): void { + this.readModels = readModels || []; + } + + setDraftReadModel(draft: DraftReadModelInfo | null): void { + this.draftReadModel = draft; + } + + // Keep for backwards compatibility + setReadModelSchemas(schemas: JsonSchema[]): void { + this.readModels = schemas.map(schema => ({ + identifier: this.getSchemaName(schema) || '', + displayName: this.getSchemaName(schema) || '', + schema + })); + } + + setEventSchemas(schemas: Record | JsonSchema[]): void { + if (!schemas) { + this.eventSchemas = {}; + return; + } + + if (Array.isArray(schemas)) { + const map: Record = {}; + schemas.forEach((s, i) => { + if (!s) return; + const name = this.getSchemaName(s) || `Event${i + 1}`; + map[name] = s; + }); + this.eventSchemas = map; + return; + } + + this.eventSchemas = schemas; + } + + validate(model: editor.ITextModel): editor.IMarkerData[] { + const markers: editor.IMarkerData[] = []; + const content = model.getValue(); + + // Skip validation if content is empty or only whitespace + // This prevents spurious errors when the model is first created before content is loaded + if (!content.trim()) { + return markers; + } + + // Normalize line endings and split + const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + + // Find the first non-empty, non-comment line + let firstNonEmptyLineIndex = 0; + let firstLine = ''; + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed && !trimmed.startsWith('#')) { + firstNonEmptyLineIndex = i; + firstLine = trimmed; + break; + } + } + + if (!firstLine) { + // This should not happen since we already checked for empty content above, + // but if it does (content is all comments), just return without error + return markers; + } + + // Validate first meaningful line - projection declaration + // Supports both "projection Name => ReadModel" and ad hoc "projection Name" + const projectionWithReadModelMatch = firstLine.match(/^projection\s+([\w.]+)\s*=>\s*([\w.]+)\s*$/); + const projectionAdHocMatch = !projectionWithReadModelMatch && firstLine.match(/^projection\s+([\w.]+)\s*$/); + + if (!projectionWithReadModelMatch && !projectionAdHocMatch) { + const lineNumber = firstNonEmptyLineIndex + 1; + markers.push(this.createError(lineNumber, 1, firstLine.length + 1, 'Projection definition must start with "projection " or "projection => "')); + return markers; + } + + let activeSchema: JsonSchema | undefined; + let readModelName: string | null = null; + + if (projectionWithReadModelMatch) { + readModelName = projectionWithReadModelMatch[2]; + + // Validate read model exists in schemas or is a draft + const readModelExists = this.resolveReadModelIdentifier(readModelName) !== null; + const isDraftReadModel = this.isDraftReadModel(readModelName); + const readModelStartColumn = this.getReadModelStartColumn(firstLine, readModelName); + + if (!readModelExists && !isDraftReadModel) { + const lineNumber = firstNonEmptyLineIndex + 1; + const col = readModelStartColumn; + markers.push(this.createError(lineNumber, col, col + readModelName.length, `Read model '${readModelName}' not found`)); + } else if (isDraftReadModel) { + // Show info marker for draft read models - they exist but haven't been saved yet + const lineNumber = firstNonEmptyLineIndex + 1; + const col = readModelStartColumn; + markers.push(this.createInfo(lineNumber, col, col + readModelName.length, `Read model '${readModelName}' is a draft (not yet saved)`)); + } + + // Get the active read model schema for property validation (check draft first) + const isDraft = this.isDraftReadModel(readModelName); + const activeReadModel = isDraft ? null : this.resolveReadModelInfo(readModelName); + activeSchema = isDraft ? this.draftReadModel!.schema : activeReadModel?.schema; + } + // For ad hoc projections (no read model), activeSchema stays undefined — property validation is skipped + + // Validate subsequent lines + const contextStack: JsonSchema[] = [activeSchema!]; + const indentStack: number[] = [0]; // Track indentation levels for context + let lastIndent = 0; + + // Track event types at each context level to detect duplicates + // Each entry in the stack represents events seen at that context level + type EventInfo = { lineNumber: number; col: number }; + const eventContextStack: Map[] = [new Map()]; + + for (let i = 1; i < lines.length; i++) { + const lineNumber = i + 1; + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('#')) continue; + + // Calculate current indentation + const currentIndent = line.search(/\S/); + + // Pop context when dedenting (exiting a children block) + while (currentIndent < lastIndent && indentStack.length > 1) { + const poppedIndent = indentStack.pop(); + if (poppedIndent !== undefined && currentIndent < poppedIndent) { + contextStack.pop(); + eventContextStack.pop(); + } + } + lastIndent = currentIndent; + + // Get the current event context (events seen at this level) + const currentEventContext = eventContextStack[eventContextStack.length - 1]; + + // Validate event types in from/every blocks + const fromMatch = trimmed.match(/^from\s+([\w-]+)/); + if (fromMatch) { + const eventType = fromMatch[1]; + const col = line.indexOf(eventType) + 1; + + if (Object.keys(this.eventSchemas).length > 0 && !this.eventSchemas[eventType]) { + markers.push(this.createError(lineNumber, col, col + eventType.length, `Event type '${eventType}' not found`)); + } + + // Check for duplicate events at this level + if (currentEventContext.has(eventType)) { + markers.push(this.createError(lineNumber, col, col + eventType.length, `Duplicate event type '${eventType}' - event types can only be used once at each level`)); + } else { + currentEventContext.set(eventType, { lineNumber, col }); + } + } + + // Validate event types in join/remove blocks + const eventsMatch = trimmed.match(/^events\s+([\w-]+)/); + if (eventsMatch) { + const eventType = eventsMatch[1]; + const col = line.indexOf(eventType) + 1; + + if (Object.keys(this.eventSchemas).length > 0 && !this.eventSchemas[eventType]) { + markers.push(this.createError(lineNumber, col, col + eventType.length, `Event type '${eventType}' not found`)); + } + + // Check for duplicate events at this level (within join blocks) + if (currentEventContext.has(eventType)) { + markers.push(this.createError(lineNumber, col, col + eventType.length, `Duplicate event type '${eventType}' - event types can only be used once at each level`)); + } else { + currentEventContext.set(eventType, { lineNumber, col }); + } + } + + const removeWithMatch = trimmed.match(/^remove\s+(?:with|via\s+join\s+on)\s+([\w-]+)/); + if (removeWithMatch) { + const eventType = removeWithMatch[1]; + const col = line.indexOf(eventType) + 1; + + if (Object.keys(this.eventSchemas).length > 0 && !this.eventSchemas[eventType]) { + markers.push(this.createError(lineNumber, col, col + eventType.length, `Event type '${eventType}' not found`)); + } + + // Check for duplicate events at this level + if (currentEventContext.has(eventType)) { + markers.push(this.createError(lineNumber, col, col + eventType.length, `Duplicate event type '${eventType}' - event types can only be used once at each level`)); + } else { + currentEventContext.set(eventType, { lineNumber, col }); + } + } + + // Validate property assignments + const assignmentMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/); + if (assignmentMatch) { + const currentSchema = contextStack[contextStack.length - 1]; + if (currentSchema?.properties) { + const [, propertyName] = assignmentMatch; + if (!currentSchema.properties[propertyName]) { + const col = line.indexOf(propertyName) + 1; + const schemaName = this.getSchemaName(currentSchema) || 'current schema'; + markers.push(this.createWarning(lineNumber, col, col + propertyName.length, `Property '${propertyName}' not found in ${schemaName}`)); + } + } + } + + // Validate increment/decrement/count targets + const numericOpMatch = trimmed.match(/^(?:increment|decrement|count)\s+(\w+)$/); + if (numericOpMatch) { + const currentSchema = contextStack[contextStack.length - 1]; + if (currentSchema?.properties) { + const [, propertyName] = numericOpMatch; + if (!currentSchema.properties[propertyName]) { + const col = line.indexOf(propertyName) + 1; + const schemaName = this.getSchemaName(currentSchema) || 'current schema'; + markers.push(this.createWarning(lineNumber, col, col + propertyName.length, `Property '${propertyName}' not found in ${schemaName}`)); + } + } + } + + // Validate add/subtract targets + const addSubMatch = trimmed.match(/^(?:add|subtract)\s+(\w+)\s+by\s+/); + if (addSubMatch) { + const currentSchema = contextStack[contextStack.length - 1]; + if (currentSchema?.properties) { + const [, propertyName] = addSubMatch; + if (!currentSchema.properties[propertyName]) { + const col = line.indexOf(propertyName) + 1; + const schemaName = this.getSchemaName(currentSchema) || 'current schema'; + markers.push(this.createWarning(lineNumber, col, col + propertyName.length, `Property '${propertyName}' not found in ${schemaName}`)); + } + } + } + + // Validate children references + const childrenMatch = trimmed.match(/^children\s+(\w+)\s+identified\s+by\s+/); + if (childrenMatch) { + const currentSchema = contextStack[contextStack.length - 1]; + if (currentSchema?.properties) { + const [, propertyName] = childrenMatch; + const prop = currentSchema.properties[propertyName]; + if (!prop) { + const col = line.indexOf(propertyName) + 1; + const schemaName = this.getSchemaName(currentSchema) || 'current schema'; + markers.push(this.createWarning(lineNumber, col, col + propertyName.length, `Collection property '${propertyName}' not found in ${schemaName}`)); + } else if (prop.type !== 'array' && !prop.items) { + const col = line.indexOf(propertyName) + 1; + markers.push(this.createWarning(lineNumber, col, col + propertyName.length, `Property '${propertyName}' is not a collection (array) type`)); + } else { + // Push child schema onto context stack for validating nested properties + const childSchema = prop.items; + if (childSchema) { + contextStack.push(childSchema); + indentStack.push(currentIndent); + // Push a new event context for the children block + eventContextStack.push(new Map()); + } + } + } + } + + // Validate join references + const joinMatch = trimmed.match(/^join\s+(\w+)\s+on\s+(\w+)/); + if (joinMatch && activeSchema?.properties) { + const [, collectionName, joinProperty] = joinMatch; + const collectionProp = activeSchema.properties[collectionName]; + if (!collectionProp) { + const col = line.indexOf(collectionName) + 1; + markers.push(this.createWarning(lineNumber, col, col + collectionName.length, `Collection property '${collectionName}' not found in read model '${readModelName}'`)); + } else if (collectionProp.type !== 'array' && !collectionProp.items) { + const col = line.indexOf(collectionName) + 1; + markers.push(this.createWarning(lineNumber, col, col + collectionName.length, `Property '${collectionName}' is not a collection (array) type`)); + } + + if (!activeSchema.properties[joinProperty]) { + const col = line.indexOf(joinProperty, line.indexOf('on')) + 1; + markers.push(this.createWarning(lineNumber, col, col + joinProperty.length, `Property '${joinProperty}' not found in read model '${readModelName}'`)); + } + + // Push a new event context for the join block + eventContextStack.push(new Map()); + indentStack.push(currentIndent); + } + + // Validate key references for composite keys + const keyMatch = trimmed.match(/^key\s+(\w+)\s*$/); + if (keyMatch && activeSchema) { + const [, keyName] = keyMatch; + // Check if it's a composite key type (should exist in definitions or as a property) + const isInDefinitions = activeSchema.definitions && activeSchema.definitions[keyName]; + const isProperty = activeSchema.properties && activeSchema.properties[keyName]; + + if (!isInDefinitions && !isProperty && !this.isBuiltInExpression(keyName)) { + const col = line.indexOf(keyName) + 1; + markers.push(this.createWarning(lineNumber, col, col + keyName.length, `Composite key type '${keyName}' not found in read model schema`)); + } + } + } + + return markers; + } + + private resolveReadModelInfo(readModelToken: string): ReadModelInfo | null { + return this.readModels.find(rm => rm.identifier === readModelToken || rm.displayName === readModelToken) || null; + } + + private getReadModelStartColumn(firstLine: string, readModelName: string): number { + const prefixMatch = firstLine.match(/^\s*projection\s+[\w.]+\s*=>\s*/); + if (!prefixMatch) { + const fallbackIndex = firstLine.lastIndexOf(readModelName); + return (fallbackIndex >= 0 ? fallbackIndex : 0) + 1; + } + return prefixMatch[0].length + 1; + } + + private resolveReadModelIdentifier(readModelToken: string): string | null { + if (this.isDraftReadModel(readModelToken)) { + return this.draftReadModel!.identifier; + } + const readModel = this.resolveReadModelInfo(readModelToken); + return readModel?.identifier ?? null; + } + + private isDraftReadModel(readModelToken: string): boolean { + return !!this.draftReadModel && (this.draftReadModel.identifier === readModelToken || this.draftReadModel.displayName === readModelToken); + } + + private isBuiltInExpression(text: string): boolean { + return text.startsWith('$') || ['true', 'false', 'null'].includes(text); + } + + private createError(line: number, startCol: number, endCol: number, message: string): editor.IMarkerData { + return { + severity: 8, // Error + startLineNumber: line, + startColumn: startCol, + endLineNumber: line, + endColumn: endCol, + message, + }; + } + + private createWarning(line: number, startCol: number, endCol: number, message: string): editor.IMarkerData { + return { + severity: 4, // Warning + startLineNumber: line, + startColumn: startCol, + endLineNumber: line, + endColumn: endCol, + message, + }; + } + + private createInfo(line: number, startCol: number, endCol: number, message: string): editor.IMarkerData { + return { + severity: 2, // Info (hint) - shows as blue/green squiggly + startLineNumber: line, + startColumn: startCol, + endLineNumber: line, + endColumn: endCol, + message, + }; + } + + private getSchemaName(schema: JsonSchema): string | undefined { + if (schema.name) return schema.name; + if (schema.title) return schema.title; + if (typeof schema.$id === 'string') { + const parts = schema.$id.split('/'); + return parts[parts.length - 1] || schema.$id; + } + return undefined; + } +} diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/index.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/index.ts new file mode 100644 index 0000000..e4faafa --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/index.ts @@ -0,0 +1,223 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type * as Monaco from 'monaco-editor'; +import { configuration, languageId, monarchLanguage } from './language'; +import { CompletionProvider } from './CompletionProvider'; +import { Validator } from './Validator'; +import { HoverProvider } from './HoverProvider'; +import { CodeActionProvider } from './CodeActionProvider'; + +export interface JsonSchema { + title?: string; + name?: string; + $id?: string; + $ref?: string; + type?: string; + format?: string; + description?: string; + properties?: Record; + items?: JsonSchema; + required?: string[]; + definitions?: Record; +} + +export interface JsonSchemaProperty { + id?: string; + name?: string; + type?: string; + format?: string; + description?: string; + items?: JsonSchema; + properties?: Record; + required?: boolean; + $ref?: string; +} + +export interface ReadModelInfo { + identifier: string; + displayName: string; + schema: JsonSchema; +} + +// Kept as its own type (rather than duplicated inline in the validator and code action +// provider, as the Chronicle original had it) since both consume the exact same shape. +export interface DraftReadModelInfo { + identifier: string; + displayName: string; + containerName: string; + schema: JsonSchema; +} + +let validator: Validator | undefined; +let completionProvider: CompletionProvider | undefined; +let hoverProvider: HoverProvider | undefined; +let codeActionProvider: CodeActionProvider | undefined; +let disposables: Monaco.IDisposable[] = []; +let monacoInstance: typeof Monaco | null = null; +let isRegistered = false; +let pendingCreateReadModelCallback: ((readModelName: string) => void) | null = null; +let pendingEditReadModelCallback: ((readModelName: string, currentSchema: JsonSchema) => void) | null = null; + +export function registerProjectionLanguage(monaco: typeof Monaco): { dispose(): void } { + if (!isRegistered) { + isRegistered = true; + monacoInstance = monaco; + + monaco.languages.register({ id: languageId }); + monaco.languages.setLanguageConfiguration(languageId, configuration); + monaco.languages.setMonarchTokensProvider(languageId, monarchLanguage); + + validator = new Validator(); + completionProvider = new CompletionProvider(); + hoverProvider = new HoverProvider(); + codeActionProvider = new CodeActionProvider(); + + // Apply pending callbacks if they were set before initialization + if (pendingCreateReadModelCallback) { + codeActionProvider.setCreateReadModelCallback(pendingCreateReadModelCallback); + pendingCreateReadModelCallback = null; + } + if (pendingEditReadModelCallback) { + codeActionProvider.setEditReadModelCallback(pendingEditReadModelCallback); + pendingEditReadModelCallback = null; + } + + // Register completion provider with helpful trigger characters + disposables.push(monaco.languages.registerCompletionItemProvider(languageId, { + provideCompletionItems: completionProvider.provideCompletionItems.bind(completionProvider), + triggerCharacters: ['.', ' ', '=', '\n', '$'], + })); + + // Register hover provider + disposables.push(monaco.languages.registerHoverProvider(languageId, { + provideHover: hoverProvider.provideHover.bind(hoverProvider), + })); + + // Register code action provider + disposables.push(monaco.languages.registerCodeActionProvider(languageId, { + provideCodeActions: codeActionProvider.provideCodeActions.bind(codeActionProvider), + }, { + providedCodeActionKinds: ['quickfix'] + })); + + // Register command for creating read models + disposables.push(monaco.editor.registerCommand('pdl.createReadModel', (_accessor: unknown, readModelName: string) => { + codeActionProvider?.invokeCreateReadModel(readModelName); + })); + + // Register command for editing read models + disposables.push(monaco.editor.registerCommand('pdl.editReadModel', (_accessor: unknown, readModelName: string, currentSchema: JsonSchema) => { + codeActionProvider?.invokeEditReadModel(readModelName, currentSchema); + })); + + // Register validation on model change + disposables.push(monaco.editor.onDidCreateModel((model: Monaco.editor.ITextModel) => { + if (model.getLanguageId() === languageId) { + validateModel(monaco, model); + disposables.push(model.onDidChangeContent(() => validateModel(monaco, model))); + } + })); + + // Validate existing models + monaco.editor.getModels().forEach((model: Monaco.editor.ITextModel) => { + if (model.getLanguageId() === languageId) { + validateModel(monaco, model); + } + }); + } + + return { dispose }; +} + +function dispose(): void { + disposables.forEach((disposable) => disposable.dispose()); + disposables = []; + isRegistered = false; + monacoInstance = null; +} + +export function setReadModelSchema(schema: JsonSchema): void { + // Backwards compatible single-schema setter + validator?.setReadModelSchemas([schema]); + completionProvider?.setReadModelSchemas([schema]); +} + +export function setReadModelSchemas(readModels: ReadModelInfo[]): void { + validator?.setReadModels(readModels); + completionProvider?.setReadModels(readModels); + hoverProvider?.setReadModels(readModels); + codeActionProvider?.setReadModels(readModels); + revalidateAllModels(); +} + +export function setCreateReadModelCallback(callback: (readModelName: string) => void): void { + if (codeActionProvider) { + codeActionProvider.setCreateReadModelCallback(callback); + } else { + // Store callback for later when the provider is initialized + pendingCreateReadModelCallback = callback; + } +} + +export function setEditReadModelCallback(callback: (readModelName: string, currentSchema: JsonSchema) => void): void { + if (codeActionProvider) { + codeActionProvider.setEditReadModelCallback(callback); + } else { + // Store callback for later when the provider is initialized + pendingEditReadModelCallback = callback; + } +} + +export function setDraftReadModel(draft: DraftReadModelInfo | null): void { + codeActionProvider?.setDraftReadModel(draft); + validator?.setDraftReadModel(draft); + hoverProvider?.setDraftReadModel(draft); + revalidateAllModels(); +} + +export function setEventSchemas(eventSchemas: JsonSchema[] | Record): void { + // Normalize either an array of schemas or a keyed record into a record keyed by derived schema name + const normalize = (input: JsonSchema[] | Record): Record => { + if (!input) return {}; + if (Array.isArray(input)) { + const out: Record = {}; + input.forEach((s, i) => { + if (!s) return; + const name = s.title || s.name || (typeof s.$id === 'string' ? s.$id.split('/').pop() : undefined) || `Event${i + 1}`; + out[name] = s; + }); + return out; + } + return input; + }; + + const normalized = normalize(eventSchemas); + validator?.setEventSchemas(normalized); + completionProvider?.setEventSchemas(normalized); + hoverProvider?.setEventSchemas(normalized); + revalidateAllModels(); +} + +export function setEventSequences(sequences: string[]): void { + completionProvider?.setEventSequences(sequences); +} + +function validateModel(monaco: typeof Monaco, model: Monaco.editor.ITextModel): void { + if (validator && !model.isDisposed()) { + const markers = validator.validate(model); + monaco.editor.setModelMarkers(model, 'pdl-validator', markers); + } +} + +function revalidateAllModels(): void { + if (monacoInstance) { + monacoInstance.editor.getModels().forEach((model: Monaco.editor.ITextModel) => { + if (model.getLanguageId() === languageId) { + validateModel(monacoInstance!, model); + } + }); + } +} + +export { languageId }; diff --git a/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/language.ts b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/language.ts new file mode 100644 index 0000000..c52037a --- /dev/null +++ b/Source/Screenplay/Monaco/screenplay-language/sub-languages/projection/language.ts @@ -0,0 +1,193 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { languages } from 'monaco-editor'; + +export const languageId = 'pdl'; + +// Keywords for the Projection Declaration Language +const KEYWORDS = [ + 'projection', + 'sequence', + 'all', + 'every', + 'on', + 'from', + 'key', + 'parent', + 'join', + 'automap', + 'no', + 'children', + 'nested', + 'identified', + 'by', + 'add', + 'subtract', + 'remove', + 'via', + 'with', + 'default', + 'increment', + 'decrement', + 'count', + 'set', + 'unset', + 'clear', + 'events', + 'id', + 'exclude', +] as const; + +// Built-in expressions and functions +const BUILTINS = [ + '$eventSourceId', + '$causedBy', + '$occurred', + '$namespace', + '$eventContext', +] as const; + +// Operators +const OPERATORS = ['=', '+=', '-=', '=>', '.']; + +export const configuration: languages.LanguageConfiguration = { + comments: { + lineComment: '#', + }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + { open: '`', close: '`' }, + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + { open: '`', close: '`' }, + ], + indentationRules: { + increaseIndentPattern: /^.*(:|\bon\b|\ball\b|\bevery\b|\bchildren\b|\bnested\b|\bparent\b)\s*$/, + decreaseIndentPattern: /^.*\}.*$/, + }, + folding: { + offSide: true, + markers: { + start: /^\s*(projection|all|every|on|children|nested|parent)/, + end: /^\s*$/, + }, + }, + wordPattern: /@?[a-zA-Z_$][\w$-]*/, +}; + +export const monarchLanguage: languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.pdl', + + keywords: KEYWORDS, + builtins: BUILTINS, + operators: OPERATORS, + + // Symbols + symbols: /[=>/, 'operator.arrow'], + [ + /@symbols/, + { + cases: { + '@operators': 'operator', + '@default': '', + }, + }, + ], + + // Numbers + [/\d*\.\d+([eE][-+]?\d+)?/, 'number.float'], + [/0[xX][0-9a-fA-F]+/, 'number.hex'], + [/\d+/, 'number'], + + // Delimiter: after number because of .\d floats + [/[;,.]/, 'delimiter'], + + // Strings + [/"([^"\\]|\\.)*$/, 'string.invalid'], + [/"/, { token: 'string.quote', next: '@string' }], + ], + + whitespace: [ + [/[ \t\r\n]+/, ''], + [/#.*$/, 'comment'], + ], + + // Template string state + templateString: [ + [/\$\{/, { token: 'delimiter.bracket', next: '@templateExpression' }], + [/[^\\`$]+/, 'string.template'], + [/@escapes/, 'string.template.escape'], + [/\\./, 'string.template.escape.invalid'], + [/`/, { token: 'string.template', next: '@pop' }], + ], + + // Template expression inside ${} + templateExpression: [ + [/\}/, { token: 'delimiter.bracket', next: '@pop' }], + { include: '@root' }, + ], + + // Regular string state + string: [ + [/[^\\"]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, { token: 'string.quote', next: '@pop' }], + ], + }, +}; From 09eac1ff300980b3b736151b5be2f472b27f3b1e Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 29 Jul 2026 06:05:07 +0200 Subject: [PATCH 3/3] Publish @cratis/screenplay-language to npm Add a publish-npm-packages job modeled on the Components repo's existing OIDC trusted-publish job: no long-lived npm token, just the GitHub Actions OIDC token exchanged at publish time. Mark the package publicly accessible via publishConfig, required for a scoped package. --- .github/workflows/publish.yml | 67 +++++++++++++++++++ .../Monaco/screenplay-language/package.json | 3 + 2 files changed, 70 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 659172d..b539189 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -112,6 +112,73 @@ jobs: npm_config_legacy_peer_deps: "true" run: npx @vscode/vsce publish --no-dependencies --pat ${{ secrets.VSCE_PAT }} + publish-npm-packages: + if: needs.release.outputs.publish == 'true' + runs-on: ubuntu-latest + needs: [release] + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 23.x + registry-url: 'https://registry.npmjs.org' + + - name: Configure npm for OIDC-based publishing + run: | + # setup-node writes a placeholder _authToken (XXXXX-XXXXX-XXXXX-XXXXX) into + # .npmrc and exports NODE_AUTH_TOKEN with the same placeholder. npm uses this + # token as-is for registry auth → 404. Remove it so npm falls back to OIDC. + sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG" + echo "NODE_AUTH_TOKEN=" >> "$GITHUB_ENV" + + - name: Upgrade npm for trusted publishing (requires >= 11.5.1) + run: | + npm install -g npm@11 + NPM_VER="$(npm --version)" + node -e " + const v = '$NPM_VER'.split('.').map(Number); + if (v[0] < 11 || (v[0] === 11 && v[1] < 5) || (v[0] === 11 && v[1] === 5 && v[2] < 1)) { + console.error('npm >= 11.5.1 is required for trusted publishing, got $NPM_VER'); + process.exit(1); + } + " + + - uses: actions/cache@v4 + id: yarn-cache + with: + path: | + .yarn/cache + **/node_modules + **/.eslintcache + **/yarn.lock + key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }} + + - name: Yarn install + run: yarn + + - name: Build language service + run: yarn workspace @cratis/screenplay-language build + + - name: Set package version + working-directory: ./Source/Screenplay/Monaco/screenplay-language + # Same reasoning as the VSCode extension step above - avoid npm version's own + # git/workspace-protocol handling and just write the version field directly. + run: npm pkg set version=${{ needs.release.outputs.version }} + + - name: Publish to npm + working-directory: ./Source/Screenplay/Monaco/screenplay-language + run: npm publish --provenance + + - name: Git reset (package.json version changed) + run: git reset --hard + publish-dotnet-packages: if: needs.release.outputs.publish == 'true' runs-on: ubuntu-latest diff --git a/Source/Screenplay/Monaco/screenplay-language/package.json b/Source/Screenplay/Monaco/screenplay-language/package.json index 4fd2d89..99360b1 100644 --- a/Source/Screenplay/Monaco/screenplay-language/package.json +++ b/Source/Screenplay/Monaco/screenplay-language/package.json @@ -7,6 +7,9 @@ "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "publishConfig": { + "access": "public" + }, "exports": { ".": { "types": "./dist/index.d.ts",