@cratis/screenplay-language 4.49.0 → 4.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/DestinationHint.ts +8 -0
  2. package/ProductionSymbol.ts +11 -0
  3. package/completion-items.ts +14 -0
  4. package/completion-planner.ts +6 -0
  5. package/completions.ts +4 -2
  6. package/diagnostic-codes.ts +10 -0
  7. package/diagnostics.ts +3 -1
  8. package/dist/DestinationHint.d.ts +6 -0
  9. package/dist/DestinationHint.d.ts.map +1 -0
  10. package/dist/DestinationHint.js +4 -0
  11. package/dist/DestinationHint.js.map +1 -0
  12. package/dist/ProductionSymbol.d.ts +13 -0
  13. package/dist/ProductionSymbol.d.ts.map +1 -0
  14. package/dist/ProductionSymbol.js +4 -0
  15. package/dist/ProductionSymbol.js.map +1 -0
  16. package/dist/completion-items.d.ts +2 -0
  17. package/dist/completion-items.d.ts.map +1 -1
  18. package/dist/completion-items.js +12 -0
  19. package/dist/completion-items.js.map +1 -1
  20. package/dist/completion-planner.d.ts.map +1 -1
  21. package/dist/completion-planner.js +7 -0
  22. package/dist/completion-planner.js.map +1 -1
  23. package/dist/completions.d.ts.map +1 -1
  24. package/dist/completions.js +4 -1
  25. package/dist/completions.js.map +1 -1
  26. package/dist/diagnostic-codes.d.ts +10 -0
  27. package/dist/diagnostic-codes.d.ts.map +1 -1
  28. package/dist/diagnostic-codes.js +10 -0
  29. package/dist/diagnostic-codes.js.map +1 -1
  30. package/dist/diagnostics.d.ts.map +1 -1
  31. package/dist/diagnostics.js +3 -1
  32. package/dist/diagnostics.js.map +1 -1
  33. package/dist/document-context.d.ts +1 -0
  34. package/dist/document-context.d.ts.map +1 -1
  35. package/dist/document-context.js +24 -2
  36. package/dist/document-context.js.map +1 -1
  37. package/dist/event-analysis-source.d.ts +2 -0
  38. package/dist/event-analysis-source.d.ts.map +1 -0
  39. package/dist/event-analysis-source.js +19 -0
  40. package/dist/event-analysis-source.js.map +1 -0
  41. package/dist/hover-content.d.ts.map +1 -1
  42. package/dist/hover-content.js +9 -1
  43. package/dist/hover-content.js.map +1 -1
  44. package/dist/index.d.ts +3 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/inlay-hints.d.ts +3 -0
  49. package/dist/inlay-hints.d.ts.map +1 -0
  50. package/dist/inlay-hints.js +15 -0
  51. package/dist/inlay-hints.js.map +1 -0
  52. package/dist/inline-event-validation.d.ts +4 -0
  53. package/dist/inline-event-validation.d.ts.map +1 -0
  54. package/dist/inline-event-validation.js +131 -0
  55. package/dist/inline-event-validation.js.map +1 -0
  56. package/dist/keyword-docs.d.ts.map +1 -1
  57. package/dist/keyword-docs.js +5 -3
  58. package/dist/keyword-docs.js.map +1 -1
  59. package/dist/production-destinations.d.ts +8 -0
  60. package/dist/production-destinations.d.ts.map +1 -0
  61. package/dist/production-destinations.js +32 -0
  62. package/dist/production-destinations.js.map +1 -0
  63. package/dist/symbols.d.ts +5 -0
  64. package/dist/symbols.d.ts.map +1 -1
  65. package/dist/symbols.js +73 -15
  66. package/dist/symbols.js.map +1 -1
  67. package/dist/tokens.d.ts.map +1 -1
  68. package/dist/tokens.js +13 -4
  69. package/dist/tokens.js.map +1 -1
  70. package/dist/validation.d.ts +1 -1
  71. package/dist/validation.d.ts.map +1 -1
  72. package/dist/validation.js +21 -0
  73. package/dist/validation.js.map +1 -1
  74. package/document-context.ts +22 -2
  75. package/event-analysis-source.ts +18 -0
  76. package/for_documentContext/when_stripping_comments.ts +32 -0
  77. package/for_inlineEvents/when_analyzing_many_inline_events.ts +59 -0
  78. package/for_inlineEvents/when_authoring_inline_events.ts +149 -0
  79. package/for_inlineEvents/when_completing_many_events.ts +49 -0
  80. package/for_inlineEvents/when_indexing_command_production_ownership.ts +79 -0
  81. package/for_scanDocument/when_scanning_many_commands.ts +47 -0
  82. package/for_tokens/when_highlighting_event_metadata.ts +63 -0
  83. package/for_validateLines/when_validating_a_document/with_a_problem_of_every_coded_kind.ts +17 -0
  84. package/for_validateLines/when_validating_production_destinations/with_an_available_identifier.ts +120 -0
  85. package/hover-content.ts +9 -1
  86. package/index.ts +5 -0
  87. package/inlay-hints.ts +17 -0
  88. package/inline-event-validation.ts +115 -0
  89. package/keyword-docs.ts +5 -3
  90. package/package.json +1 -1
  91. package/production-destinations.ts +32 -0
  92. package/symbols.ts +75 -15
  93. package/tokens.ts +14 -4
  94. package/validation.ts +23 -1
@@ -0,0 +1,149 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { describe, it, expect } from 'vitest';
5
+ import { planCompletions } from '../completion-planner';
6
+ import { hoverContent } from '../hover-content';
7
+ import { destinationHints } from '../production-destinations';
8
+ import { scanDocument } from '../symbols';
9
+ import { validateLines } from '../validation';
10
+
11
+ const source = [
12
+ 'command Rename', ' projectId Uuid identifier', ' name String', ' produces event Renamed',
13
+ ' description "A new name"', ' documentation', ' ```markdown', ' event NotADeclaration', ' ```',
14
+ ' @tag String = name', ' name String = name',
15
+ ];
16
+
17
+ describe('when authoring inline events', () => {
18
+ it('should index the event and its typed properties, not its prose', () => {
19
+ const symbols = scanDocument(source);
20
+ expect(symbols.events.map(event => event.name)).toEqual(['Renamed']);
21
+ expect(symbols.events[0].properties.map(property => property.name)).toEqual(['tag', 'name']);
22
+ expect(symbols.commands[0].properties.map(property => property.name)).toEqual(['projectId', 'name']);
23
+ });
24
+ it('should validate its metadata without false positives', () => expect(validateLines(source)).toEqual([]));
25
+ it.each([
26
+ [' documentation "details"'],
27
+ [' documentation'],
28
+ [' documentation', ' ```text', ' Details', ' ```'],
29
+ [' documentation', ' ```markdown', ' Details', ' ```'],
30
+ [' documentation', ' ```markdown', ' ', ' ```'],
31
+ [' documentation', ' ```markdown', ' Details'],
32
+ ])('should reject malformed documentation without searching later declarations for its fence', (...body) => {
33
+ const lines = ['command Rename', ' produces event Renamed', ...body];
34
+ expect(validateLines(lines).filter(issue => issue.code === 'PLAY0477').map(issue => issue.line)).toEqual([2]);
35
+ });
36
+ it('should distinguish adjacent complete documentation blocks from malformed directives', () => {
37
+ const lines = ['event First', ' documentation "details"', 'event Second', ' documentation',
38
+ ' ```markdown', ' Details', ' ```', 'event Third', ' documentation',
39
+ ' ```markdown', ' ', ' ```', 'event Fourth', ' documentation', '',
40
+ ' ```markdown', ' More details', ' ```'];
41
+ expect(validateLines(lines).filter(issue => issue.code === 'PLAY0477').map(issue => issue.line)).toEqual([1, 8]);
42
+ });
43
+ it('should offer event metadata and typed mappings inside the body', () => {
44
+ const plan = planCompletions([...source, ' '], source.length, ' ');
45
+ expect(plan.kind).toBe('entries');
46
+ if (plan.kind === 'entries') expect(plan.entries.map(entry => entry.label)).toEqual(expect.arrayContaining(['description', 'documentation', 'id', 'tag', 'for', 'property mapping']));
47
+ });
48
+ it('should show the implicit command identifier', () => expect(destinationHints(source).map(hint => hint.label)).toEqual(['for projectId']));
49
+ it('should suppress hints for uncertain mixed destinations', () => expect(destinationHints([...source, ' produces Another', ' for otherId'])).toEqual([]));
50
+ it('should suppress hints for incomplete and repeated explicit destinations', () => {
51
+ expect(destinationHints([...source, ' for'])).toEqual([]);
52
+ expect(destinationHints([...source, ' for projectId', ' for projectId'])).toEqual([]);
53
+ });
54
+ it('should not hint a header that has no event name', () => expect(destinationHints(['command Rename', ' produces'])).toEqual([]));
55
+ it('should explain the new declaration form', () => expect(hoverContent(source, 3, 'produces', 3, 11)).toContain('produces event'));
56
+ it('should preserve information severity for redundant pins', () => expect(validateLines([...source, ' id "Renamed"']).find(issue => issue.code === 'PLAY0471')?.severity).toBe('information'));
57
+ it.each(['namespace', 'sequence', 'correlation', 'causation', 'causedBy', 'occurred'])('should reject %s as system metadata', keyword => {
58
+ expect(validateLines([...source, ` ${keyword} = name`]).map(issue => issue.code)).toContain('PLAY0476');
59
+ });
60
+ it('should warn about identifier duplication', () => expect(validateLines([...source, ' projectId Uuid = projectId']).find(issue => issue.code === 'PLAY0469')?.severity).toBe('warning'));
61
+ it('should reject an inline event in a reaction', () => expect(validateLines(['reaction React', ' every 1 day', ' produces event Done']).map(issue => issue.code)).toContain('PLAY0474'));
62
+ it('should require explicit destinations for mixed inline and plain omissions', () => {
63
+ const lines = [...source, ' produces Legacy // retained allocation'];
64
+ expect(validateLines(lines).filter(issue => issue.code === 'PLAY0470').map(issue => issue.message)).toContain("Production 'Legacy' must state for explicitly when destinations differ.");
65
+ expect(destinationHints(lines)).toEqual([]);
66
+ });
67
+ it('should suppress legacy allocation hints when a sibling can promote a destination', () => {
68
+ expect(destinationHints(['command Rename', ' projectId Uuid identifier', ' produces First', ' for projectId', ' produces Legacy'])).toEqual([]);
69
+ });
70
+ it.each([
71
+ [' produces First', ' name = name', ' for projectId', ' produces Legacy'],
72
+ [' produces First', ' name = name', ' for projectId', ' produces Legacy'],
73
+ [' produces First', ' name = name', ' for projectId', ' produces Legacy'],
74
+ [' produces when name != ""', ' First', ' name = name', ' for projectId', ' produces Legacy'],
75
+ ])('should not hint allocation when a differently indented destination is promoted', (...productions) => {
76
+ const lines = ['command Rename', ' projectId Uuid identifier', ' name String', ...productions];
77
+ expect(scanDocument(lines).commands[0].produces?.[0].target).toBe('projectId');
78
+ expect(destinationHints(lines)).toEqual([]);
79
+ });
80
+ it('should see deeper destinations after fenced event documentation', () => {
81
+ const lines = ['command Rename', ' projectId Uuid identifier', ' produces event Renamed',
82
+ ' documentation', ' ```markdown', ' Details', ' ```', ' for otherId', ' produces Legacy'];
83
+ expect(scanDocument(lines).commands[0].produces?.[0].target).toBe('otherId');
84
+ expect(destinationHints(lines)).toEqual([]);
85
+ });
86
+ it('should suppress hints when a sibling production cannot be classified', () => {
87
+ expect(destinationHints(['command Rename', ' projectId Uuid identifier', ' produces First', ' produces event'])).toEqual([]);
88
+ expect(destinationHints(['command Rename', ' projectId Uuid identifier', ' produces event'])).toEqual([]);
89
+ expect(destinationHints(['command Rename', ' produces First', ' produces when'])).toEqual([]);
90
+ });
91
+ it('should place a destination hint before a trailing comment', () => {
92
+ const lines = ['command Rename', ' projectId Uuid identifier', ' produces event Renamed // note'];
93
+ expect(destinationHints(lines)).toEqual([{ line: 2, column: ' produces event Renamed'.length + 1, label: 'for projectId' }]);
94
+ });
95
+ it('should report duplicate typed properties at the repeated mapping', () => {
96
+ const lines = [...source, ' name Uuid = otherId // duplicate'];
97
+ expect(validateLines(lines).filter(issue => issue.code === 'PLAY0168').map(issue => ({ line: issue.line, severity: issue.severity })))
98
+ .toEqual([{ line: source.length, severity: 'error' }]);
99
+ });
100
+ it('should treat escaped and unescaped inline property names as the same name', () => {
101
+ expect(validateLines([...source, ' @name String = name']).filter(issue => issue.code === 'PLAY0168').map(issue => issue.line)).toEqual([source.length]);
102
+ });
103
+ it('should detect collisions with commented imports in the document', () => {
104
+ expect(validateLines(['import Other.Renamed // note', ...source]).filter(issue => issue.code === 'PLAY0473').map(issue => issue.line)).toEqual([4]);
105
+ });
106
+ it('should detect collisions with commented imports from other files', () => {
107
+ const application = scanDocument(['import Other.Renamed // note']);
108
+ expect(validateLines(source, { application }).filter(issue => issue.code === 'PLAY0473').map(issue => issue.line)).toEqual([3]);
109
+ });
110
+ it('should not scan imports inside event documentation', () => {
111
+ const lines = source.map(line => line === ' event NotADeclaration' ? ' import Other.Renamed // prose' : line);
112
+ expect(scanDocument(lines).imports).toEqual([]);
113
+ expect(validateLines(lines)).toEqual([]);
114
+ });
115
+ it('should hint allocation when all plain productions omit destinations', () => {
116
+ expect(destinationHints(['command Rename', ' produces First', ' produces Legacy']).map(hint => hint.label)).toEqual(['for <new event source>', 'for <new event source>']);
117
+ });
118
+ it('should analyze commented headers, destinations, mappings and metadata like the compiler', () => {
119
+ const lines = source.map(line => line === ' documentation' ? `${line} // details` : line);
120
+ lines.push(' projectId Uuid = projectId // note', ' produces Other // sibling', ' for projectId // same identifier');
121
+ expect(validateLines(lines).filter(issue => ['PLAY0470', 'PLAY0477'].includes(issue.code ?? ''))).toEqual([]);
122
+ expect(validateLines(lines).find(issue => issue.code === 'PLAY0469')?.severity).toBe('warning');
123
+ expect(destinationHints(lines).map(hint => hint.label)).toEqual(['for projectId']);
124
+ });
125
+ it('should suppress hints when a commented sibling targets another source', () => {
126
+ const lines = [...source, ' produces Other // sibling', ' for otherId // other source'];
127
+ expect(validateLines(lines).some(issue => issue.code === 'PLAY0470')).toBe(true);
128
+ expect(destinationHints(lines)).toEqual([]);
129
+ });
130
+ it('should preserve comment markers inside strings and templates and ignore fences', () => {
131
+ const lines = [...source, ' url String = "https://example.org" // url', ' template String = `https://{name}` // template'];
132
+ expect(scanDocument(lines).commands[0].produces?.[0].mappings.map(mapping => mapping.source)).toContain('"https://example.org"');
133
+ expect(scanDocument(lines).commands[0].produces?.[0].mappings.map(mapping => mapping.source)).toContain('`https://{name}`');
134
+ expect(destinationHints(lines).map(hint => hint.label)).toEqual(['for projectId']);
135
+ const fenced = ['command Rename', ' documentation', ' ```markdown', ' produces event Fake', ' for otherId', ' ```'];
136
+ expect(destinationHints(fenced)).toEqual([]);
137
+ expect(validateLines(fenced).filter(issue => issue.code?.startsWith('PLAY047'))).toEqual([]);
138
+ });
139
+ it('should show metadata hover only on event directives', () => {
140
+ expect(hoverContent(['event Done', ' id "Old"'], 1, 'id', 3, 5)).toContain('rename');
141
+ expect(hoverContent(['event Done', ' documentation'], 1, 'documentation', 3, 16)).not.toBeNull();
142
+ for (const line of [' id String', ' key id', ' name String = id', ' id String = name']) {
143
+ const start = line.indexOf('id') + 1;
144
+ expect(hoverContent(['event Done', line], 1, 'id', start, start + 2)).toBeNull();
145
+ }
146
+ expect(hoverContent(['command Done', ' documentation'], 1, 'documentation', 3, 16)).toBeNull();
147
+ });
148
+ it('should retain standalone property-shaped id and documentation names', () => expect(scanDocument(['event Done', ' id String', ' documentation String']).events[0].properties.map(property => property.name)).toEqual(['id', 'documentation']));
149
+ });
@@ -0,0 +1,49 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import type { editor, languages, Position } from 'monaco-editor';
6
+ import { createCompletionProvider } from '../completions';
7
+ import { Monaco } from '../language';
8
+
9
+ const monaco = {
10
+ Range: class {},
11
+ languages: { CompletionItemKind: { Event: 1 }, CompletionItemInsertTextRule: { InsertAsSnippet: 1 } },
12
+ } as unknown as Monaco;
13
+
14
+ function complete(count: number, inline: boolean): { work: number; suggestions: languages.CompletionItem[] } {
15
+ const lines = [...Array.from({ length: count }, (_, index) => inline ? `command Record${index}\n produces event Recorded${index}` : `event Recorded${index}`).join('\n').split('\n'),
16
+ 'constraint Unique', ' on '];
17
+ const model = {
18
+ getLinesContent: () => lines,
19
+ getWordUntilPosition: () => ({ word: '', startColumn: 6, endColumn: 6 }),
20
+ } as unknown as editor.ITextModel;
21
+ const searches = vi.spyOn(Array.prototype, 'some');
22
+ const filters = vi.spyOn(Array.prototype, 'filter');
23
+ const membership = vi.spyOn(Set.prototype, 'has');
24
+ try {
25
+ const result = createCompletionProvider(monaco).provideCompletionItems(model, { lineNumber: lines.length, column: 6 } as Position, {} as languages.CompletionContext, {} as never) as languages.CompletionList;
26
+ return {
27
+ suggestions: result.suggestions,
28
+ work: [...searches.mock.contexts, ...filters.mock.contexts].reduce<number>((sum, receiver) => sum + (receiver as unknown[]).length, 0) + membership.mock.calls.length,
29
+ };
30
+ } finally {
31
+ searches.mockRestore();
32
+ filters.mockRestore();
33
+ membership.mockRestore();
34
+ }
35
+ }
36
+
37
+ describe('when completing many event names in Monaco', () => {
38
+ it.each([false, true])('should keep completion work linear for inline declarations: %s', inline => {
39
+ const small = complete(500, inline);
40
+ const medium = complete(1000, inline);
41
+ const large = complete(2000, inline);
42
+ expect(small.work).toBeGreaterThan(0);
43
+ expect(medium.work).toBeLessThan(small.work * 2.2);
44
+ expect(large.work).toBeLessThan(medium.work * 2.2);
45
+ expect(large.suggestions).toHaveLength(2000);
46
+ expect(large.suggestions.map(item => item.label)).toEqual(Array.from({ length: 2000 }, (_, index) => `Recorded${index}`));
47
+ expect(large.suggestions.every(item => item.detail === (inline ? 'inline event' : 'event'))).toBe(true);
48
+ });
49
+ });
@@ -0,0 +1,79 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+ import { destinationHints } from '../production-destinations';
6
+ import { scanDocument } from '../symbols';
7
+ import { validateLines } from '../validation';
8
+
9
+ describe('when indexing command production ownership', () => {
10
+ it('should keep the documented multiline authorization out of command properties', () => {
11
+ const lines = ['policy IsAccountant', ' require role "Accountant"', 'policy IsCustomerSelf', ' require authenticated',
12
+ 'command Rename', ' projectId Uuid identifier',
13
+ ' authorize IsAccountant', ' or IsCustomerSelf', ' produces event Renamed'];
14
+ const command = scanDocument(lines).commands[0];
15
+ expect(command.properties.map(property => property.name)).toEqual(['projectId']);
16
+ expect(command.produces?.map(production => production.name)).toEqual(['Renamed']);
17
+ expect(validateLines(lines)).toEqual([]);
18
+ expect(destinationHints(lines).map(hint => hint.label)).toEqual(['for projectId']);
19
+ });
20
+
21
+ it.each(['or IsCustomerSelf', 'and IsCustomerSelf', 'IsCustomerSelf', '(IsCustomerSelf)', 'or IsCüstomerSelf', 'produces Ghost'])('should consume %s as an authorization continuation', continuation => {
22
+ const lines = ['command Rename', ' authorize IsAccountant', ` ${continuation}`, ' produces Legacy'];
23
+ const command = scanDocument(lines).commands[0];
24
+ expect(command.properties).toEqual([]);
25
+ expect(command.productionHeaders).toEqual([3]);
26
+ expect(destinationHints(lines).map(hint => hint.line)).toEqual([3]);
27
+ });
28
+
29
+ it('should end authorization ownership at the first nonmatching line, not merely a dedent', () => {
30
+ const lines = ['command Rename', ' projectId Uuid identifier', ' authorize IsAccountant',
31
+ ' or IsCustomerSelf // continuation', ' names String[]', ' produces event Renamed'];
32
+ const command = scanDocument(lines).commands[0];
33
+ expect(command.properties.map(property => property.name)).toEqual(['projectId', 'names']);
34
+ expect(command.productionHeaders).toEqual([5]);
35
+ expect(destinationHints(lines).map(hint => hint.label)).toEqual(['for projectId']);
36
+ });
37
+
38
+ it.each([
39
+ [[' description "Rename the project"']],
40
+ [[' description', ' ```text', ' produces Ghost', ' ```']],
41
+ ])('should leave deeper syntax after a description in its command', description => {
42
+ const lines = ['command Rename', ' projectId Uuid identifier', ...description, ' produces event Renamed'];
43
+ const command = scanDocument(lines).commands[0];
44
+ expect(command.properties.map(property => property.name)).toEqual(['projectId']);
45
+ expect(command.produces?.map(production => production.name)).toEqual(['Renamed']);
46
+ expect(destinationHints(lines).map(hint => hint.label)).toEqual(['for projectId']);
47
+ });
48
+
49
+ it.each(['validate', 'handler', 'concurrency', 'name'])('should keep a deeper production after the %s property in its command', name => {
50
+ const lines = ['command Rename', ' projectId Uuid identifier', ` ${name} String`,
51
+ ' produces event Renamed', ' value String = "renamed"'];
52
+ expect(scanDocument(lines).commands[0].produces?.map(production => production.name)).toEqual(['Renamed']);
53
+ expect(validateLines(lines)).toEqual([]);
54
+ expect(destinationHints(lines).map(hint => hint.label)).toEqual(['for projectId']);
55
+ });
56
+
57
+ it.each(['validate', 'handler', 'concurrency'])('should not hint allocation after a deeper destination beneath the %s property', name => {
58
+ const lines = ['command Rename', ' projectId Uuid identifier', ` ${name} String // a property, not a block`,
59
+ ' produces First', ' for projectId', ' produces Legacy'];
60
+ expect(scanDocument(lines).commands[0].produces?.map(production => [production.name, production.target]))
61
+ .toEqual([['First', 'projectId'], ['Legacy', undefined]]);
62
+ expect(destinationHints(lines)).toEqual([]);
63
+ expect(validateLines(lines).filter(issue => issue.code === 'PLAY0478').map(issue => issue.line)).toEqual([5]);
64
+ });
65
+
66
+ it.each(['validate', 'handler', 'concurrency'])('should suppress hints for an unclassifiable production beneath the %s property', name => {
67
+ expect(destinationHints(['command Rename', ' projectId Uuid identifier', ` ${name} String`,
68
+ ' produces event', ' produces Legacy'])).toEqual([]);
69
+ });
70
+
71
+ it.each(['validate', 'validate csharp', 'handler', 'concurrency'])('should keep the %s block opaque to command ownership', directive => {
72
+ const lines = ['command Rename', ` ${directive}`, ' ghost Uuid identifier', ' produces First', ' for ghost', ' produces Legacy'];
73
+ const command = scanDocument(lines).commands[0];
74
+ expect(command.properties).toEqual([]);
75
+ expect(command.produces?.map(production => production.name)).toEqual(['Legacy']);
76
+ expect(destinationHints(lines).map(hint => hint.line)).toEqual([5]);
77
+ expect(validateLines(lines).filter(issue => issue.code === 'PLAY0478')).toEqual([]);
78
+ });
79
+ });
@@ -0,0 +1,47 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { describe, it, vi } from 'vitest';
5
+ import * as context from '../document-context';
6
+ import { scanDocument } from '../symbols';
7
+ import { validateLines } from '../validation';
8
+
9
+ function commands(count: number): string[] {
10
+ return ['module Projects', ' feature Registration', ...Array.from({ length: count }, (_, index) => [
11
+ ` slice StateChange Register${index}`,
12
+ ` command Register${index} // command`,
13
+ ' description Uuid identifier // keyword-named property',
14
+ ' details String',
15
+ ' produces Registered',
16
+ ' nested Uuid identifier',
17
+ ' // a comment is not an ancestor',
18
+ ' handler',
19
+ ' ```csharp',
20
+ ' ghost Uuid identifier',
21
+ ' ```',
22
+ ' name String',
23
+ ]).flat()];
24
+ }
25
+
26
+ describe('when scanning many commands', () => {
27
+ it('should visit structural indentation a linear number of times', () => {
28
+ const visits = vi.spyOn(context, 'indentOf');
29
+ try {
30
+ const small = commands(150);
31
+ validateLines(small);
32
+ const smallCount = visits.mock.calls.length;
33
+ visits.mockClear();
34
+ const large = commands(300);
35
+ validateLines(large);
36
+ const largeCount = visits.mock.calls.length;
37
+ const symbols = scanDocument(large);
38
+ smallCount.should.be.greaterThan(0);
39
+ largeCount.should.be.lessThan(large.length * 10);
40
+ largeCount.should.be.lessThan(smallCount * 2.1);
41
+ symbols.commands.length.should.equal(300);
42
+ symbols.commands.every(command => command.properties.map(property => property.name).join(',') === 'description,details,name').should.equal(true);
43
+ } finally {
44
+ visits.mockRestore();
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,63 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+ import type { Token } from 'monaco-editor';
6
+ import { clauseKeywords } from '../language';
7
+ import { createTokensProvider } from '../tokens';
8
+
9
+ const provider = createTokensProvider([]);
10
+ const rules = provider.tokenizer.eventBody as unknown as [RegExp, unknown][];
11
+
12
+ describe('when highlighting event metadata', () => {
13
+ it.each(['id', 'documentation'])('should leave %s available as an ordinary name', word => {
14
+ expect(clauseKeywords).not.toContain(word);
15
+ });
16
+ it('should recognize only directive-shaped metadata inside the event state', () => {
17
+ expect(rules[1][0].test(' id "Old"')).toBe(true);
18
+ expect(rules[1][0].test(' id String')).toBe(false);
19
+ expect(rules[1][0].test(' name String = id')).toBe(false);
20
+ expect(rules[2][0].test(' documentation // details')).toBe(true);
21
+ expect(rules[2][0].test(' documentation String')).toBe(false);
22
+ });
23
+ it('should tokenize event directives without claiming property names or later blocks', async () => {
24
+ const compilerPath = 'monaco-editor/esm/vs/editor/standalone/common/monarch/monarchCompile.js';
25
+ const lexerPath = 'monaco-editor/esm/vs/editor/standalone/common/monarch/monarchLexer.js';
26
+ const { compile } = await import(compilerPath);
27
+ const { MonarchTokenizer } = await import(lexerPath);
28
+ const tokenizer = new MonarchTokenizer({}, {}, 'screenplay', compile('screenplay', provider), {
29
+ getValue: () => 20000,
30
+ onDidChangeConfiguration: () => ({ dispose() {} }),
31
+ });
32
+ let state = tokenizer.getInitialState();
33
+ const tokenize = (line: string): Token[] => {
34
+ const result = tokenizer.tokenize(line, true, state);
35
+ state = result.endState;
36
+ return result.tokens;
37
+ };
38
+ try {
39
+ for (const header of [' event Done', ' produces event Done']) {
40
+ tokenize(header);
41
+ expect(tokenize(' id "Old"').find(token => token.offset === 4)?.type).toBe('keyword.play');
42
+ expect(tokenize(' id String').find(token => token.offset === 4)?.type).toBe('identifier.play');
43
+ expect(tokenize(' documentation String').find(token => token.offset === 4)?.type).toBe('identifier.play');
44
+ const documentation = tokenize(' documentation // details');
45
+ expect(documentation.find(token => token.offset === 4)?.type).toBe('keyword.play');
46
+ expect(documentation.find(token => token.offset === 18)?.type).toBe('comment.play');
47
+ expect(tokenize(' ```markdown').find(token => token.type === 'string.quote.play')).toBeDefined();
48
+ expect(tokenize(' event Prose')[0].type).toBe('string.play');
49
+ tokenize(' ```');
50
+ tokenize(' command Other');
51
+ expect(tokenize(' id "NotMetadata"').find(token => token.offset === 4)?.type).toBe('identifier.play');
52
+ }
53
+ } finally {
54
+ tokenizer.dispose();
55
+ }
56
+ });
57
+ it('should leave the event state when a line is not nested under its header', () => {
58
+ const exit = new RegExp(rules[0][0].source.replace('$S2', ' '));
59
+ expect(exit.test(' command Other')).toBe(true);
60
+ expect(exit.test(' id "Old"')).toBe(false);
61
+ expect(exit.test('')).toBe(false);
62
+ });
63
+ });
@@ -28,6 +28,9 @@ const document = [
28
28
  ' registeredBy = $context.wat',
29
29
  ' causedBy = $context.causedBy.wat',
30
30
  ' department = $context.identity.wat',
31
+ ' command ArchiveInvoice',
32
+ ' invoiceId InvoiceId identifier',
33
+ ' produces InvoiceArchived',
31
34
  ' projection Statistics => StatisticsReadModel',
32
35
  ' from InvoiceRegistered',
33
36
  ' cause = $eventContext.causationId',
@@ -35,6 +38,20 @@ const document = [
35
38
  ' firstCause = $eventContext.causation.occurred',
36
39
  ' nothing = $eventContext.',
37
40
  ' count byUser.$causedBy.subject',
41
+ ' command Rename',
42
+ ' invoiceId InvoiceId identifier',
43
+ ' otherId InvoiceId',
44
+ ' produces event Renamed generation 2',
45
+ ' invoiceId InvoiceId = invoiceId',
46
+ ' id "Renamed"',
47
+ ' id "Older"',
48
+ ' documentation "not fenced"',
49
+ ' produces Other',
50
+ ' for otherId',
51
+ ' event Renamed',
52
+ ' reaction React',
53
+ ' every 1 day',
54
+ ' produces event Unsupported',
38
55
  ' query Overdue => OverdueReadModel[]',
39
56
  ' performer',
40
57
  ' csharp',
@@ -0,0 +1,120 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, describe, it } from 'vitest';
5
+ import { diagnosticCodes } from '../../diagnostic-codes';
6
+ import { ValidationIssue, validateLines } from '../../validation';
7
+
8
+ const source = [
9
+ 'module Projects',
10
+ ' feature Registration',
11
+ ' slice StateChange Register',
12
+ ' command Register',
13
+ ' projectId Uuid identifier',
14
+ ' produces ProjectRegistered // omitted destination',
15
+ ' name = name',
16
+ ' produces ProjectUpdated',
17
+ ' for projectId // explicit destination',
18
+ ' produces when true == true',
19
+ ' ProjectConditional',
20
+ ' event ProjectRegistered',
21
+ ' event ProjectUpdated',
22
+ ' event ProjectConditional',
23
+ ];
24
+
25
+ const destinations = (lines: string[]) => validateLines(lines).filter(issue => issue.code === diagnosticCodes.omittedProductionDestination);
26
+
27
+ describe('when validating production destinations with an available identifier', () => {
28
+ let issues: ValidationIssue[];
29
+
30
+ beforeEach(() => {
31
+ issues = destinations(source);
32
+ });
33
+
34
+ it('should advise only on the plain omitted destination', () => {
35
+ issues.map(issue => issue.line).should.deep.equal([5]);
36
+ });
37
+
38
+ it('should keep the advice informational', () => {
39
+ issues[0].severity.should.equal('information');
40
+ issues[0].message.should.contain('for projectId');
41
+ });
42
+
43
+ it('should not advise without an identifier', () => {
44
+ destinations(source.map(line => line.replace(' identifier', ''))).should.have.lengthOf(0);
45
+ });
46
+
47
+ it.each(['Uuid?', 'Uuid[]', 'Uuid[]?'])('should not advise for a %s identifier', (type) => {
48
+ destinations(source.map(line => line.replace('projectId Uuid identifier', `projectId ${type} identifier`))).should.have.lengthOf(0);
49
+ });
50
+
51
+ it.each([
52
+ ['command Register // explanation', 'projectId Uuid identifier'],
53
+ ['command Register', 'projectId Uuid identifier // explanation'],
54
+ ['command Register // explanation', 'projectId Uuid identifier // explanation'],
55
+ ])('should discover identifiers despite trailing comments: %s; %s', (command, property) => {
56
+ destinations(source.map(line => line.replace('command Register', command).replace('projectId Uuid identifier', property)))
57
+ .map(issue => issue.line).should.deep.equal([5]);
58
+ });
59
+
60
+ it.each(['// outside', ' // beside the command', ' // beside the production'])(
61
+ 'should skip comment-only ancestry lines: %s', (comment) => {
62
+ destinations([...source.slice(0, 5), comment, ...source.slice(5)])
63
+ .map(issue => issue.line).should.deep.equal([6]);
64
+ });
65
+
66
+ it.each(['description', 'handler', 'concurrency', 'validate', 'template', 'profile', 'when', 'identifier', 'sourceType', 'streamType'])(
67
+ 'should follow command property rules for %s', (name) => {
68
+ const issues = destinations(source.map(line => line.replaceAll('projectId', name)));
69
+ issues.map(issue => issue.line).should.deep.equal([5]);
70
+ issues[0].message.should.contain(`for ${name}`);
71
+ });
72
+
73
+ it('should not treat a nested concurrency dimension as a command identifier', () => {
74
+ destinations([
75
+ ...source.slice(0, 4),
76
+ ' concurrency',
77
+ ' sourceType Uuid identifier',
78
+ ' produces ProjectRegistered',
79
+ ' event ProjectRegistered',
80
+ ]).should.have.lengthOf(0);
81
+ });
82
+
83
+ it('should not discover an identifier inside a fence', () => {
84
+ destinations([
85
+ ...source.slice(0, 4),
86
+ ' produces ProjectRegistered',
87
+ ' validate',
88
+ ' ```csharp',
89
+ ' ghostId Uuid identifier',
90
+ ' ```',
91
+ ' event ProjectRegistered',
92
+ ]).should.have.lengthOf(0);
93
+ });
94
+
95
+ it('should not count fenced identifiers beside a real identifier', () => {
96
+ destinations([
97
+ ...source.slice(0, 5),
98
+ ' produces ProjectRegistered',
99
+ ' validate',
100
+ ' ```csharp',
101
+ ' ghostId Uuid identifier',
102
+ ' ```',
103
+ ' event ProjectRegistered',
104
+ ]).map(issue => issue.line).should.deep.equal([5]);
105
+ });
106
+
107
+ it('should not advise on reaction productions', () => {
108
+ destinations(source.map(line => line.replace('command Register', 'reaction Register'))).should.have.lengthOf(0);
109
+ });
110
+
111
+ it('should ignore fenced code that looks like a production', () => {
112
+ destinations([
113
+ ...source.slice(0, 5),
114
+ ' handler',
115
+ ' ```csharp',
116
+ ' produces NotScreenplay',
117
+ ' ```',
118
+ ]).should.have.lengthOf(0);
119
+ });
120
+ });
package/hover-content.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import { enclosingChain, fenceMap, indentOf } from './document-context';
5
- import { scanDocument } from './symbols';
5
+ import { directBody, scanDocument } from './symbols';
6
+ import { eventAnalysisSource } from './event-analysis-source';
6
7
  import { getSubLanguage } from './sub-language-registry';
7
8
  import { attributeDocs, contextVariableDocs, keywordDocs, specificationKeywordDocs } from './keyword-docs';
8
9
 
@@ -102,6 +103,13 @@ export function hoverContent(
102
103
  return `**${word}** — ${specificationKeywordDocs[word]}`;
103
104
  }
104
105
 
106
+ if (word === 'id' || word === 'documentation') {
107
+ const source = eventAnalysisSource(lines);
108
+ const inEvent = symbols.events.some(event => directBody(source, fences, event.line, indentOf(source[event.line])).includes(lineIndex));
109
+ const directive = word === 'id' ? /^\s*id\s+"/.test(source[lineIndex]) : /^\s*documentation\s*$/.test(source[lineIndex]);
110
+ if (!inEvent || !directive || startColumn !== indentOf(line) + 1) return null;
111
+ }
112
+
105
113
  const keywordDoc = keywordDocs[word];
106
114
  if (keywordDoc) return `**${word}** — ${keywordDoc}`;
107
115
 
package/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  import { createTokensProvider } from './tokens';
11
11
  import { CompletionOptions, createCompletionProvider } from './completions';
12
12
  import { createHoverProvider } from './hover';
13
+ import { createInlayHintsProvider } from './inlay-hints';
13
14
  import { attachDiagnostics } from './diagnostics';
14
15
  import {
15
16
  getSubLanguage,
@@ -53,6 +54,7 @@ export function register(monaco: Monaco, options: LanguageServiceOptions = {}):
53
54
  applyTokensProvider(monaco);
54
55
  monaco.languages.registerCompletionItemProvider(languageId, createCompletionProvider(monaco, options));
55
56
  monaco.languages.registerHoverProvider(languageId, createHoverProvider());
57
+ monaco.languages.registerInlayHintsProvider(languageId, createInlayHintsProvider());
56
58
  monaco.editor.defineTheme(screenplayDarkThemeName, screenplayDark);
57
59
  monaco.editor.defineTheme(screenplayLightThemeName, screenplayLight);
58
60
  attachDiagnostics(monaco);
@@ -115,6 +117,9 @@ export { completionEntriesFor, planCompletions } from './completion-planner';
115
117
  export type { CompletionPlan } from './completion-planner';
116
118
  export { hoverContent } from './hover-content';
117
119
  export { validateLines } from './validation';
120
+ export { destinationHints, productionDestinations } from './production-destinations';
121
+ export type { DestinationHint } from './DestinationHint';
122
+ export type { ProductionSymbol } from './ProductionSymbol';
118
123
  export type { ValidationContext, ValidationIssue, ValidationSeverity } from './validation';
119
124
  export { diagnosticCodes } from './diagnostic-codes';
120
125
  export type { DiagnosticCode } from './diagnostic-codes';
package/inlay-hints.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { languages } from 'monaco-editor';
5
+ import { destinationHints } from './production-destinations';
6
+
7
+ export function createInlayHintsProvider(): languages.InlayHintsProvider {
8
+ return {
9
+ provideInlayHints(model, range) {
10
+ const hints = destinationHints(model.getLinesContent()).filter(hint => hint.line + 1 >= range.startLineNumber && hint.line + 1 <= range.endLineNumber);
11
+ return {
12
+ hints: hints.map(hint => ({ position: { lineNumber: hint.line + 1, column: hint.column }, label: hint.label, paddingLeft: true })),
13
+ dispose() {},
14
+ };
15
+ },
16
+ };
17
+ }