@cratis/screenplay-language 4.47.0 → 4.48.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.
- package/completion-items.ts +37 -1
- package/completion-planner.ts +15 -0
- package/completions.ts +26 -3
- package/diagnostic-codes.ts +1 -0
- package/dist/completion-items.d.ts +1 -0
- package/dist/completion-items.d.ts.map +1 -1
- package/dist/completion-items.js +36 -1
- package/dist/completion-items.js.map +1 -1
- package/dist/completion-planner.d.ts +3 -0
- package/dist/completion-planner.d.ts.map +1 -1
- package/dist/completion-planner.js +15 -0
- package/dist/completion-planner.js.map +1 -1
- package/dist/completions.d.ts +5 -2
- package/dist/completions.d.ts.map +1 -1
- package/dist/completions.js +13 -2
- package/dist/completions.js.map +1 -1
- package/dist/diagnostic-codes.d.ts +1 -0
- package/dist/diagnostic-codes.d.ts.map +1 -1
- package/dist/diagnostic-codes.js +1 -0
- package/dist/diagnostic-codes.js.map +1 -1
- package/dist/file-imports.d.ts +11 -0
- package/dist/file-imports.d.ts.map +1 -0
- package/dist/file-imports.js +58 -0
- package/dist/file-imports.js.map +1 -0
- package/dist/hover-content.d.ts.map +1 -1
- package/dist/hover-content.js +5 -1
- package/dist/hover-content.js.map +1 -1
- package/dist/index.d.ts +10 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/keyword-docs.d.ts +1 -0
- package/dist/keyword-docs.d.ts.map +1 -1
- package/dist/keyword-docs.js +13 -2
- package/dist/keyword-docs.js.map +1 -1
- package/dist/language.d.ts.map +1 -1
- package/dist/language.js +2 -0
- package/dist/language.js.map +1 -1
- package/dist/symbols.d.ts +1 -0
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +15 -0
- package/dist/symbols.js.map +1 -1
- package/dist/themes/screenplay-dark.d.ts.map +1 -1
- package/dist/themes/screenplay-dark.js +1 -0
- package/dist/themes/screenplay-dark.js.map +1 -1
- package/dist/themes/screenplay-light.d.ts.map +1 -1
- package/dist/themes/screenplay-light.js +1 -0
- package/dist/themes/screenplay-light.js.map +1 -1
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +6 -0
- package/dist/tokens.js.map +1 -1
- package/dist/validation.d.ts +5 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +29 -8
- package/dist/validation.js.map +1 -1
- package/file-imports.ts +70 -0
- package/for_fileImports/when_finding_file_imports.ts +38 -0
- package/for_importablePaths/when_listing_what_a_document_can_import.ts +34 -0
- package/for_planCompletions/when_completing_a_file_import.ts +26 -0
- package/for_planCompletions/when_completing_a_specification_step.ts +42 -0
- package/for_tokens/when_highlighting_a_file_import.ts +18 -0
- package/for_tokens/when_highlighting_specification_steps.ts +28 -0
- package/for_validateLines/when_validating_a_document/with_a_problem_of_every_coded_kind.ts +1 -0
- package/for_validateLines/when_validating_a_file_placed_by_an_import/with_names_declared_in_other_files.ts +43 -0
- package/for_validateLines/when_validating_file_imports/at_every_level.ts +36 -0
- package/hover-content.ts +6 -1
- package/index.ts +13 -7
- package/keyword-docs.ts +14 -2
- package/language.ts +2 -0
- package/package.json +1 -1
- package/symbols.ts +16 -0
- package/themes/screenplay-dark.ts +1 -0
- package/themes/screenplay-light.ts +1 -0
- package/tokens.ts +6 -0
- package/validation.ts +43 -7
package/file-imports.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
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 { fenceMap, indentOf } from './document-context';
|
|
5
|
+
|
|
6
|
+
// An `import "<path or glob>"` of other .play files, with the pattern exactly as written and the span it
|
|
7
|
+
// occupies between the quotes. Columns are one-based like every other position this package reports.
|
|
8
|
+
export interface FileImport {
|
|
9
|
+
pattern: string;
|
|
10
|
+
line: number;
|
|
11
|
+
startColumn: number;
|
|
12
|
+
endColumn: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The compiler's own shape for a file import (FileImportParser): a quoted operand without quotes or escapes.
|
|
16
|
+
const fileImportPattern = /^import(\s+)"([^"\\]+)"\s*(?:\/\/.*)?$/;
|
|
17
|
+
const quotedImportPattern = /^import\s+"/;
|
|
18
|
+
|
|
19
|
+
// Whether a line imports files rather than a qualified name - its operand is quoted.
|
|
20
|
+
export function isFileImportLine(line: string): boolean {
|
|
21
|
+
return quotedImportPattern.test(line.trim());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The file import on a single line, or undefined when the line is not a well-formed one.
|
|
25
|
+
export function fileImportOn(line: string, lineIndex: number): FileImport | undefined {
|
|
26
|
+
const indent = indentOf(line);
|
|
27
|
+
const match = line.slice(indent).match(fileImportPattern);
|
|
28
|
+
if (!match) return undefined;
|
|
29
|
+
const startColumn = indent + 'import'.length + match[1].length + 2;
|
|
30
|
+
return { pattern: match[2], line: lineIndex, startColumn, endColumn: startColumn + match[2].length };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Every well-formed file import in a document, in document order, skipping code fences.
|
|
34
|
+
export function fileImports(lines: string[]): FileImport[] {
|
|
35
|
+
const fences = fenceMap(lines);
|
|
36
|
+
const found: FileImport[] = [];
|
|
37
|
+
for (let index = 0; index < lines.length; index++) {
|
|
38
|
+
if (fences[index]) continue;
|
|
39
|
+
const fileImport = fileImportOn(lines[index], index);
|
|
40
|
+
if (fileImport) found.push(fileImport);
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const folderOf = (path: string): string[] => path.split('/').slice(0, -1);
|
|
46
|
+
|
|
47
|
+
function relativeTo(from: readonly string[], path: string): string {
|
|
48
|
+
const segments = path.split('/');
|
|
49
|
+
let common = 0;
|
|
50
|
+
while (common < from.length && common < segments.length - 1 && from[common] === segments[common]) common++;
|
|
51
|
+
return [...from.slice(common).map(() => '..'), ...segments.slice(common)].join('/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// What an import written in a document can name: every other .play file, relative to the document's folder;
|
|
55
|
+
// a glob for the files directly in each folder holding one; and a glob for everything beneath each folder on
|
|
56
|
+
// the way to them. Paths are portable ('/' separated) and relative to one root, the way the compiler resolves them.
|
|
57
|
+
export function importablePaths(documentPath: string, files: readonly string[]): string[] {
|
|
58
|
+
const from = folderOf(documentPath);
|
|
59
|
+
const paths = new Set<string>();
|
|
60
|
+
for (const file of files.filter((candidate) => candidate !== documentPath && candidate.toLowerCase().endsWith('.play'))) {
|
|
61
|
+
const relative = relativeTo(from, file);
|
|
62
|
+
const folders = folderOf(relative);
|
|
63
|
+
paths.add(relative);
|
|
64
|
+
paths.add([...folders, '*.play'].join('/'));
|
|
65
|
+
for (let depth = 0; depth <= folders.length; depth++) {
|
|
66
|
+
paths.add([...folders.slice(0, depth), '**', '*.play'].join('/'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...paths].sort();
|
|
70
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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, beforeEach, it } from 'vitest';
|
|
5
|
+
import { FileImport, fileImportOn, fileImports, isFileImportLine } from '../file-imports';
|
|
6
|
+
|
|
7
|
+
describe('when finding file imports', () => {
|
|
8
|
+
let found: FileImport[];
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
found = fileImports([
|
|
12
|
+
'import Customers.CustomerRegistered',
|
|
13
|
+
'import "**/*.play"',
|
|
14
|
+
'module Ordering',
|
|
15
|
+
' import "Orders/*.play" // the orders',
|
|
16
|
+
' screen Overview',
|
|
17
|
+
' ```typescript',
|
|
18
|
+
'import "not/a/play/import"',
|
|
19
|
+
' ```',
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should find the quoted imports outside code fences', () => {
|
|
24
|
+
found.map((each) => each.pattern).should.deep.equal(['**/*.play', 'Orders/*.play']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should locate the pattern between the quotes', () => {
|
|
28
|
+
found[1].should.deep.equal({ pattern: 'Orders/*.play', line: 3, startColumn: 13, endColumn: 26 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should not read a malformed import as one', () => {
|
|
32
|
+
(fileImportOn('import "a\\b.play"', 0) === undefined).should.be.true;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should tell a file import from a qualified one by its quote', () => {
|
|
36
|
+
[isFileImportLine(' import "x'), isFileImportLine('import X.Y')].should.deep.equal([true, false]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
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, beforeEach, it } from 'vitest';
|
|
5
|
+
import { importablePaths } from '../file-imports';
|
|
6
|
+
|
|
7
|
+
describe('when listing what a document can import', () => {
|
|
8
|
+
let paths: string[];
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
paths = importablePaths('Ordering/Ordering.play', [
|
|
12
|
+
'application.play',
|
|
13
|
+
'Ordering/Ordering.play',
|
|
14
|
+
'Ordering/Orders/PlaceOrder.play',
|
|
15
|
+
'Ordering/notes.md',
|
|
16
|
+
'Shared/Concepts.play',
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should name every other play file relative to the document, with globs for the folders on the way', () => {
|
|
21
|
+
paths.should.deep.equal([
|
|
22
|
+
'**/*.play',
|
|
23
|
+
'../**/*.play',
|
|
24
|
+
'../*.play',
|
|
25
|
+
'../Shared/**/*.play',
|
|
26
|
+
'../Shared/*.play',
|
|
27
|
+
'../Shared/Concepts.play',
|
|
28
|
+
'../application.play',
|
|
29
|
+
'Orders/**/*.play',
|
|
30
|
+
'Orders/*.play',
|
|
31
|
+
'Orders/PlaceOrder.play',
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
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 } from 'vitest';
|
|
5
|
+
import { completionEntriesFor, planCompletions } from '../completion-planner';
|
|
6
|
+
import { hoverContent } from '../hover-content';
|
|
7
|
+
|
|
8
|
+
describe('when completing a file import', () => {
|
|
9
|
+
it('should complete a path inside the quotes, replacing what is typed', () => {
|
|
10
|
+
planCompletions(['module Ordering', ' import "Ord'], 1, ' import "Ord').should.deep.equal({ kind: 'playFiles', replaceLength: 3 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should offer nothing after a quote outside an import', () => {
|
|
14
|
+
planCompletions(['module Ordering', ' description "'], 1, ' description "').should.deep.equal({ kind: 'none' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
for (const [chain, scope] of [[[], 'top level'], [['module'], 'module'], [['feature', 'module'], 'feature']] as const) {
|
|
18
|
+
it(`should offer a file import at the ${scope}`, () => {
|
|
19
|
+
completionEntriesFor([...chain]).some((entry) => entry.label === 'import "…"').should.be.true;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it('should document both kinds of import on hover', () => {
|
|
24
|
+
hoverContent(['import "**/*.play"'], 0, 'import', 1, 7)!.should.contain('places the imported files there');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
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 } from 'vitest';
|
|
5
|
+
import { completionEntriesFor, planCompletions } from '../completion-planner';
|
|
6
|
+
import { hoverContent } from '../hover-content';
|
|
7
|
+
|
|
8
|
+
const specification = ['slice StateView Hours', ' specification AtOpening'];
|
|
9
|
+
const labelsAfter = (textBefore: string): string[] => {
|
|
10
|
+
const plan = planCompletions([...specification, textBefore], 2, textBefore);
|
|
11
|
+
return plan.kind === 'entries' ? plan.entries.map((entry) => entry.label) : [plan.kind];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('when completing a specification step', () => {
|
|
15
|
+
it('should offer what can follow given', () => {
|
|
16
|
+
labelsAfter(' given ').should.include.members(['clock', 'capture']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should offer what can follow when', () => {
|
|
20
|
+
labelsAfter(' when ').should.include.members(['clock', 'trigger', 'capture', 'query']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should offer what can follow then', () => {
|
|
24
|
+
labelsAfter(' then res').should.include.members(['result', 'result exactly', 'no result']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should offer the queries after when query', () => {
|
|
28
|
+
labelsAfter(' when query Opening').should.deep.equal(['queries']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should offer every step on an empty line', () => {
|
|
32
|
+
completionEntriesFor(['specification']).map((entry) => entry.label).should.include.members(['given clock', 'when trigger', 'when capture', 'when query', 'then result', 'then no result']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should document a step word in a specification', () => {
|
|
36
|
+
hoverContent([...specification, ' when trigger NightlySync'], 2, 'trigger', 10, 17)!.should.contain('application trigger fires');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should document a step word not in a specification as the keyword', () => {
|
|
40
|
+
hoverContent(['trigger NightlySync'], 0, 'trigger', 1, 8)!.should.not.contain('application trigger fires');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
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 } from 'vitest';
|
|
5
|
+
import { createTokensProvider } from '../tokens';
|
|
6
|
+
|
|
7
|
+
const rules = createTokensProvider([]).tokenizer.root as unknown as [RegExp, unknown][];
|
|
8
|
+
const fileImportRule = rules.find((rule) => Array.isArray(rule[1]) && (rule[1] as string[]).includes('string.link'));
|
|
9
|
+
|
|
10
|
+
describe('when highlighting a file import', () => {
|
|
11
|
+
it('should read the quoted path as a link', () => {
|
|
12
|
+
fileImportRule![0].test(' import "Orders/**/*.play"').should.be.true;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should leave a qualified import to the keyword rules', () => {
|
|
16
|
+
fileImportRule![0].test('import Customers.CustomerRegistered').should.be.false;
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
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 } from 'vitest';
|
|
5
|
+
import { ensureBuiltInSubLanguages } from '../index';
|
|
6
|
+
import { getSubLanguages } from '../sub-language-registry';
|
|
7
|
+
import { createTokensProvider } from '../tokens';
|
|
8
|
+
|
|
9
|
+
ensureBuiltInSubLanguages();
|
|
10
|
+
const rules = createTokensProvider(getSubLanguages()).tokenizer.root as unknown as [RegExp, unknown][];
|
|
11
|
+
const stepRules = rules.filter((rule) => Array.isArray(rule[1]) && (rule[1] as string[]).join() === 'white,keyword,white,keyword');
|
|
12
|
+
const captureLanguage = rules.findIndex((rule) => rule[0].source === '\\bcapture\\b');
|
|
13
|
+
|
|
14
|
+
describe('when highlighting specification steps', () => {
|
|
15
|
+
it('should come before the capture sub-language can claim a step', () => {
|
|
16
|
+
rules.indexOf(stepRules[stepRules.length - 1]).should.be.below(captureLanguage);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
for (const line of [' given clock "2026-10-05T08:00:00Z"', ' given capture LegacyLoans', ' when trigger NightlySync', ' when query OpeningHoursFor', ' then result exactly', ' then no result']) {
|
|
20
|
+
it(`should read the step words of '${line.trim()}' as keywords`, () => {
|
|
21
|
+
stepRules.some((rule) => rule[0].test(line)).should.be.true;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it('should leave a capture declaration to the sub-language', () => {
|
|
26
|
+
stepRules.some((rule) => rule[0].test(' capture LegacyLoans')).should.be.false;
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
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, beforeEach, it } from 'vitest';
|
|
5
|
+
import { mergeSymbols, scanDocument } from '../../symbols';
|
|
6
|
+
import { ValidationIssue, validateLines } from '../../validation';
|
|
7
|
+
|
|
8
|
+
// A file an import places in a feature holds slices at its top level, and the names it uses are declared in
|
|
9
|
+
// the other files of the application.
|
|
10
|
+
describe('when validating a file placed by an import with names declared in other files', () => {
|
|
11
|
+
let issues: ValidationIssue[];
|
|
12
|
+
let alone: ValidationIssue[];
|
|
13
|
+
const fragment = [
|
|
14
|
+
'slice StateChange PlaceOrder',
|
|
15
|
+
' command PlaceOrder',
|
|
16
|
+
' orderId OrderId identifier',
|
|
17
|
+
' customer Customer',
|
|
18
|
+
' authorize Staff',
|
|
19
|
+
' produces OrderPlaced',
|
|
20
|
+
' for orderId',
|
|
21
|
+
' reaction Notify',
|
|
22
|
+
' when OrderPlaced',
|
|
23
|
+
' produces CustomerNotified',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
const application = mergeSymbols(
|
|
28
|
+
scanDocument(['concept OrderId : Uuid', 'policy Staff', ' require authenticated']),
|
|
29
|
+
scanDocument(['type Customer', ' name String', 'module Ordering', ' feature Orders', ' slice StateChange PlaceOrder', ' event OrderPlaced', ' note String']),
|
|
30
|
+
scanDocument(['import Customers.CustomerNotified']),
|
|
31
|
+
);
|
|
32
|
+
issues = validateLines(fragment, { application });
|
|
33
|
+
alone = validateLines(fragment);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should report nothing', () => {
|
|
37
|
+
issues.should.be.empty;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should report the names as unknown without the application', () => {
|
|
41
|
+
alone.map((issue) => issue.code).should.deep.equal(['PLAY0165', 'PLAY0165', 'PLAY0167', 'PLAY0166', 'PLAY0166']);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
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, beforeEach, it } from 'vitest';
|
|
5
|
+
import { diagnosticCodes } from '../../diagnostic-codes';
|
|
6
|
+
import { ValidationIssue, validateLines } from '../../validation';
|
|
7
|
+
|
|
8
|
+
describe('when validating file imports at every level', () => {
|
|
9
|
+
let issues: ValidationIssue[];
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
issues = validateLines([
|
|
13
|
+
'import Customers.CustomerRegistered',
|
|
14
|
+
'import "**/*.play" // every document',
|
|
15
|
+
'import "Bad\\Path.play"',
|
|
16
|
+
'module Ordering',
|
|
17
|
+
' import "Orders/*.play"',
|
|
18
|
+
' import Customers.CustomerRegistered',
|
|
19
|
+
' feature Orders',
|
|
20
|
+
' import "Slices/**/*.play"',
|
|
21
|
+
' import "Unclosed',
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should report the malformed and the qualified imports inside a body', () => {
|
|
26
|
+
issues.map((issue) => `${issue.code}@${issue.line}`).should.deep.equal([
|
|
27
|
+
`${diagnosticCodes.invalidFileImport}@2`,
|
|
28
|
+
`${diagnosticCodes.invalidFileImport}@5`,
|
|
29
|
+
`${diagnosticCodes.invalidFileImport}@8`,
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should span the whole import', () => {
|
|
34
|
+
issues[1].should.include({ startColumn: 3, endColumn: 3 + 'import Customers.CustomerRegistered'.length });
|
|
35
|
+
});
|
|
36
|
+
});
|
package/hover-content.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { enclosingChain, fenceMap, indentOf } from './document-context';
|
|
5
5
|
import { scanDocument } from './symbols';
|
|
6
6
|
import { getSubLanguage } from './sub-language-registry';
|
|
7
|
-
import { attributeDocs, contextVariableDocs, keywordDocs } from './keyword-docs';
|
|
7
|
+
import { attributeDocs, contextVariableDocs, keywordDocs, specificationKeywordDocs } from './keyword-docs';
|
|
8
8
|
|
|
9
9
|
// Produces the hover markdown for a word at a position, without any editor
|
|
10
10
|
// dependency — the Monaco service and the VSCode extension share this content.
|
|
@@ -97,6 +97,11 @@ export function hoverContent(
|
|
|
97
97
|
return '**reads** — Declares a whole view this clock trigger consults: `reads <View>`. Clock triggers take no values, so `by` is unavailable.';
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
const step = line.trim().match(/^(given|when|then)\s+(?:no\s+)?(\w+)/);
|
|
101
|
+
if (step && step[2] === word && chain[0] === 'specification' && specificationKeywordDocs[word]) {
|
|
102
|
+
return `**${word}** — ${specificationKeywordDocs[word]}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
100
105
|
const keywordDoc = keywordDocs[word];
|
|
101
106
|
if (keywordDoc) return `**${word}** — ${keywordDoc}`;
|
|
102
107
|
|
package/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
languageId,
|
|
9
9
|
} from './language';
|
|
10
10
|
import { createTokensProvider } from './tokens';
|
|
11
|
-
import { createCompletionProvider } from './completions';
|
|
11
|
+
import { CompletionOptions, createCompletionProvider } from './completions';
|
|
12
12
|
import { createHoverProvider } from './hover';
|
|
13
13
|
import { attachDiagnostics } from './diagnostics';
|
|
14
14
|
import {
|
|
@@ -38,7 +38,10 @@ export function ensureBuiltInSubLanguages(): void {
|
|
|
38
38
|
if (!getSubLanguage('capture')) registerSubLanguage('capture', cdl);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// What a host can tell the language service beyond the models it holds.
|
|
42
|
+
export type LanguageServiceOptions = CompletionOptions;
|
|
43
|
+
|
|
44
|
+
export function register(monaco: Monaco, options: LanguageServiceOptions = {}): void {
|
|
42
45
|
if (registeredInstances.has(monaco)) return;
|
|
43
46
|
|
|
44
47
|
ensureBuiltInSubLanguages();
|
|
@@ -48,7 +51,7 @@ export function register(monaco: Monaco): void {
|
|
|
48
51
|
monaco.languages.register(languageExtensionPoint);
|
|
49
52
|
monaco.languages.setLanguageConfiguration(languageId, languageConfiguration);
|
|
50
53
|
applyTokensProvider(monaco);
|
|
51
|
-
monaco.languages.registerCompletionItemProvider(languageId, createCompletionProvider(monaco));
|
|
54
|
+
monaco.languages.registerCompletionItemProvider(languageId, createCompletionProvider(monaco, options));
|
|
52
55
|
monaco.languages.registerHoverProvider(languageId, createHoverProvider());
|
|
53
56
|
monaco.editor.defineTheme(screenplayDarkThemeName, screenplayDark);
|
|
54
57
|
monaco.editor.defineTheme(screenplayLightThemeName, screenplayLight);
|
|
@@ -86,7 +89,10 @@ export {
|
|
|
86
89
|
isAbsoluteFileReferencePath,
|
|
87
90
|
} from './file-references';
|
|
88
91
|
export type { FileReference } from './file-references';
|
|
89
|
-
export {
|
|
92
|
+
export { fileImportOn, fileImports, importablePaths, isFileImportLine } from './file-imports';
|
|
93
|
+
export type { FileImport } from './file-imports';
|
|
94
|
+
export type { CompletionOptions } from './completions';
|
|
95
|
+
export { builtInTriggerNames, knownEventNames, knownTriggerNames, knownTypeNames, mergeSymbols, scanDocument } from './symbols';
|
|
90
96
|
export type {
|
|
91
97
|
CommandSymbol,
|
|
92
98
|
ConceptSymbol,
|
|
@@ -100,15 +106,15 @@ export type {
|
|
|
100
106
|
QuerySymbol,
|
|
101
107
|
TypeSymbol,
|
|
102
108
|
} from './symbols';
|
|
103
|
-
export { attributeDocs, contextVariableDocs, keywordDocs } from './keyword-docs';
|
|
109
|
+
export { attributeDocs, contextVariableDocs, keywordDocs, specificationKeywordDocs } from './keyword-docs';
|
|
104
110
|
export { eventContextMemberAt, eventContextMembers, eventContextMembersAfter, eventContextPaths, namesEventContextMember } from './event-context';
|
|
105
111
|
export type { EventContextMember, EventContextPath } from './event-context';
|
|
106
|
-
export { contextVariableItems, producesItems } from './completion-items';
|
|
112
|
+
export { contextVariableItems, producesItems, specificationStepItems } from './completion-items';
|
|
107
113
|
export type { CompletionEntry } from './completion-items';
|
|
108
114
|
export { completionEntriesFor, planCompletions } from './completion-planner';
|
|
109
115
|
export type { CompletionPlan } from './completion-planner';
|
|
110
116
|
export { hoverContent } from './hover-content';
|
|
111
117
|
export { validateLines } from './validation';
|
|
112
|
-
export type { ValidationIssue, ValidationSeverity } from './validation';
|
|
118
|
+
export type { ValidationContext, ValidationIssue, ValidationSeverity } from './validation';
|
|
113
119
|
export { diagnosticCodes } from './diagnostic-codes';
|
|
114
120
|
export type { DiagnosticCode } from './diagnostic-codes';
|
package/keyword-docs.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { eventContextPaths } from './event-context';
|
|
|
5
5
|
|
|
6
6
|
export const keywordDocs: Record<string, string> = {
|
|
7
7
|
domain: 'The domain the file belongs to — optional, at most one per file, before everything else.',
|
|
8
|
-
import: 'Imports a type from another module
|
|
8
|
+
import: 'Imports a type from another module by its qualified name — `import Module.Type` — making it available by its short name; or, quoted, other `.play` files by path or glob — `import "Orders/**/*.play"`, relative to this file\'s folder. Written inside a module or feature, a file import places the imported files there: their top level is that module\'s or feature\'s body.',
|
|
9
9
|
concept: 'A formalized value type wrapping a primitive. Attributes such as `@pii` travel with every usage, optionally with a `<attribute> reason "..."` line saying why.',
|
|
10
10
|
type: 'A composite value type — a named shape built from several properties, referenced by events, commands and other types the same way a concept is.',
|
|
11
11
|
reason: 'Records why a concept attribute applies — the purpose, the lawful basis, whose subject the value lives under. Written as `pii reason "..."` in the concept body.',
|
|
@@ -71,7 +71,9 @@ export const keywordDocs: Record<string, string> = {
|
|
|
71
71
|
readmodel: 'Read model state in a specification — `given readmodel` establishes it, `then readmodel` asserts it, `then no readmodel <View> for <key>` asserts keyed absence.',
|
|
72
72
|
no: 'A keyed absence assertion: `then no readmodel <View> for <key>`.',
|
|
73
73
|
arguments: 'The values supplied to a query asserted by `then query`.',
|
|
74
|
-
result: 'One expected result
|
|
74
|
+
result: 'One expected result - `then result [exactly]` after `when query`, or a `result` block inside `then query`. Repeat it to assert several results in authored order.',
|
|
75
|
+
clock: 'The instant a specification happens at - `given clock "2026-10-05T08:00:00Z"` fixes when everything occurs; `when clock "<instant>"` is the clock reaching it, as the action.',
|
|
76
|
+
exactly: 'Requires every property to match, rather than only the ones asserted - on `then readmodel`, `then query` and `then result`.',
|
|
75
77
|
file: 'Delegates the implementation to an external file; the Screenplay contract stays visible.',
|
|
76
78
|
by: 'Introduces the identifying parameter.',
|
|
77
79
|
via: 'Connects screen data to the query that supplies it.',
|
|
@@ -107,6 +109,16 @@ export const keywordDocs: Record<string, string> = {
|
|
|
107
109
|
Enum: 'An enumeration concept with a fixed set of values.',
|
|
108
110
|
};
|
|
109
111
|
|
|
112
|
+
// What the words of a specification's steps mean there - where 'trigger', 'capture' and 'query' name what the
|
|
113
|
+
// scenario exercises rather than declare one.
|
|
114
|
+
export const specificationKeywordDocs: Record<string, string> = {
|
|
115
|
+
clock: keywordDocs.clock,
|
|
116
|
+
trigger: '`when trigger <Trigger>` - an application trigger fires, as the action, with the values on the lines beneath it.',
|
|
117
|
+
capture: '`given capture <Capture>` - a record the capture\'s source held before; `when capture <Capture>` - the record it sees now, as the action. The fields go on the lines beneath.',
|
|
118
|
+
query: '`when query <Query>` - the query is performed, as the action, with its arguments on the lines beneath; assert what it returns with `then result` or `then no result`. `then query` instead asserts a query after another action.',
|
|
119
|
+
result: '`then result [exactly]` - one result the query performed by `when query` returns, in order; `then no result` - it returns nothing.',
|
|
120
|
+
};
|
|
121
|
+
|
|
110
122
|
export const attributeDocs: Record<string, string> = {
|
|
111
123
|
pii: '`@pii` — the value is personally identifiable information; Chronicle manages and erases it automatically wherever the concept is used. Add `pii reason "..."` in the concept body to record why.',
|
|
112
124
|
sensitive: '`@sensitive` — the value is sensitive; Chronicle applies its sensitivity rules automatically wherever the concept is used. Add `sensitive reason "..."` in the concept body to record why.',
|
package/language.ts
CHANGED
package/package.json
CHANGED
package/symbols.ts
CHANGED
|
@@ -266,6 +266,22 @@ export function scanDocument(lines: string[]): DocumentSymbols {
|
|
|
266
266
|
return symbols;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
// The symbols of several documents as one - what an application spread over files declares. Lines refer to
|
|
270
|
+
// the document each symbol came from, so the result names things; it does not locate them.
|
|
271
|
+
export function mergeSymbols(...documents: DocumentSymbols[]): DocumentSymbols {
|
|
272
|
+
return {
|
|
273
|
+
imports: documents.flatMap((document) => document.imports),
|
|
274
|
+
concepts: documents.flatMap((document) => document.concepts),
|
|
275
|
+
types: documents.flatMap((document) => document.types),
|
|
276
|
+
policies: documents.flatMap((document) => document.policies),
|
|
277
|
+
events: documents.flatMap((document) => document.events),
|
|
278
|
+
commands: documents.flatMap((document) => document.commands),
|
|
279
|
+
queries: documents.flatMap((document) => document.queries),
|
|
280
|
+
screens: documents.flatMap((document) => document.screens),
|
|
281
|
+
triggers: documents.flatMap((document) => document.triggers),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
269
285
|
export function knownEventNames(symbols: DocumentSymbols): string[] {
|
|
270
286
|
return [
|
|
271
287
|
...symbols.events.map((event) => event.name),
|
|
@@ -18,6 +18,7 @@ export const screenplayDark: editor.IStandaloneThemeData = {
|
|
|
18
18
|
{ token: 'annotation.play', foreground: 'DCDCAA' },
|
|
19
19
|
{ token: 'variable.predefined.play', foreground: 'D7BA7D' },
|
|
20
20
|
{ token: 'string.play', foreground: 'CE9178' },
|
|
21
|
+
{ token: 'string.link.play', foreground: 'CE9178', fontStyle: 'underline' },
|
|
21
22
|
{ token: 'string.invalid.play', foreground: 'F44747' },
|
|
22
23
|
{ token: 'string.quote.play', foreground: '6A9955' },
|
|
23
24
|
{ token: 'number.play', foreground: 'B5CEA8' },
|
|
@@ -18,6 +18,7 @@ export const screenplayLight: editor.IStandaloneThemeData = {
|
|
|
18
18
|
{ token: 'annotation.play', foreground: '795E26' },
|
|
19
19
|
{ token: 'variable.predefined.play', foreground: 'B45309' },
|
|
20
20
|
{ token: 'string.play', foreground: 'A31515' },
|
|
21
|
+
{ token: 'string.link.play', foreground: 'A31515', fontStyle: 'underline' },
|
|
21
22
|
{ token: 'string.invalid.play', foreground: 'CD3131' },
|
|
22
23
|
{ token: 'string.quote.play', foreground: '008000' },
|
|
23
24
|
{ token: 'number.play', foreground: '098658' },
|
package/tokens.ts
CHANGED
|
@@ -53,6 +53,12 @@ export const commonTokenRules: MonarchTokenRules = [
|
|
|
53
53
|
export function createTokensProvider(subLanguages: SubLanguage[]): languages.IMonarchLanguage {
|
|
54
54
|
const tokenizer: Record<string, MonarchTokenRules> = {
|
|
55
55
|
root: [
|
|
56
|
+
// A specification's clock, trigger, capture and query steps - matched before a sub-language keyword
|
|
57
|
+
// can claim 'capture' and read the rest of the specification as change data capture.
|
|
58
|
+
[/^(\s*)(given|when)(\s+)(clock|capture|trigger|query)\b/, ['white', 'keyword', 'white', 'keyword']],
|
|
59
|
+
[/^(\s*)(then)(\s+)(result|no\s+result)\b/, ['white', 'keyword', 'white', 'keyword']],
|
|
60
|
+
// A quoted import names .play files rather than a qualified name - the path reads as a link.
|
|
61
|
+
[/^(\s*)(import)(\s+)("[^"\\]*")/, ['white', 'keyword', 'white', 'string.link']],
|
|
56
62
|
// Only an event header reserves 'generation'; properties and context paths do not.
|
|
57
63
|
[/^(\s*)(event)(\s+)([A-Za-z_]\w*)(\s+)(generation)(?=\s+\d+\s*$)/,
|
|
58
64
|
['white', 'keyword', 'white', 'type.identifier', 'white', 'keyword']],
|