@lokascript/framework 3.1.1 → 3.2.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/CHANGELOG.md +145 -0
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/tokenization/index.js +1 -1
- package/dist/core/tokenization/index.js.map +1 -1
- package/dist/index.cjs +65 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +65 -23
- package/dist/index.js.map +1 -1
- package/dist/interfaces/value-extractor.d.ts.map +1 -1
- package/dist/ir/from-interchange.d.ts.map +1 -1
- package/dist/ir/index.js +61 -19
- package/dist/ir/index.js.map +1 -1
- package/dist/ir/to-runtime-ast.d.ts.map +1 -1
- package/dist/multilingual/index.js +1 -1
- package/dist/multilingual/index.js.map +1 -1
- package/dist/testing/index.js +8178 -8572
- package/dist/testing/index.js.map +1 -1
- package/package.json +5 -5
- package/src/core/tokenization/apostrophe-possessive.test.ts +12 -0
- package/src/interfaces/value-extractor.ts +5 -1
- package/src/ir/from-interchange.test.ts +16 -2
- package/src/ir/from-interchange.ts +17 -5
- package/src/ir/to-runtime-ast.test.ts +113 -0
- package/src/ir/to-runtime-ast.ts +82 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lokascript/framework",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Generic framework for building multilingual DSLs with semantic parsing and grammar transformation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -96,15 +96,15 @@
|
|
|
96
96
|
"author": "LokaScript Contributors",
|
|
97
97
|
"license": "MIT",
|
|
98
98
|
"dependencies": {
|
|
99
|
-
"@lokascript/intent": "^3.
|
|
99
|
+
"@lokascript/intent": "^3.2.0"
|
|
100
100
|
},
|
|
101
101
|
"devDependencies": {
|
|
102
102
|
"@lokascript/domains": "^3.0.0",
|
|
103
|
-
"@types/node": "^26.
|
|
104
|
-
"@vitest/coverage-v8": "^
|
|
103
|
+
"@types/node": "^26.6.3",
|
|
104
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
105
105
|
"tsup": "^8.0.0",
|
|
106
106
|
"typescript": "^5.0.0",
|
|
107
|
-
"vitest": "^
|
|
107
|
+
"vitest": "^5.0.2"
|
|
108
108
|
},
|
|
109
109
|
"files": [
|
|
110
110
|
"dist",
|
|
@@ -25,6 +25,18 @@ describe('StringLiteralExtractor: apostrophe after a word character', () => {
|
|
|
25
25
|
expect(extractor.canExtract(input, position)).toBe(false);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
// A word can end in a combining mark: bn, hi and th write `event` with a
|
|
29
|
+
// final vowel sign or tone mark (PR 109: `ঘটনা's target's id` lexed `'s
|
|
30
|
+
// target'` as one string).
|
|
31
|
+
it.each(["ঘটনা's target's id", "घटना's target's id", "เหตุการณ์'s target's id"])(
|
|
32
|
+
'does not open a string after a combining mark in %s',
|
|
33
|
+
input => {
|
|
34
|
+
for (let i = input.indexOf("'"); i >= 0; i = input.indexOf("'", i + 1)) {
|
|
35
|
+
expect(extractor.canExtract(input, i)).toBe(false);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
28
40
|
it.each([
|
|
29
41
|
["put 'hello' into me", 4],
|
|
30
42
|
["log 'a' + 'b'", 4],
|
|
@@ -92,7 +92,11 @@ export class StringLiteralExtractor implements ValueExtractor {
|
|
|
92
92
|
// literal, which swallowed the operator and hid the property noun from
|
|
93
93
|
// translation in every language that renders `'s`. A quote that opens a
|
|
94
94
|
// string always follows whitespace, punctuation, or the start of input.
|
|
95
|
-
|
|
95
|
+
// A word can end in a combining mark: bn `ঘটনা`, hi `घटना` and th
|
|
96
|
+
// `เหตุการณ์` (event) end in a vowel sign or a tone mark, and without
|
|
97
|
+
// `\p{M}` their `'s` paired with the next one (`ঘটনা's target's id` lexed
|
|
98
|
+
// `'s target'` as a string).
|
|
99
|
+
if (char === "'" && position > 0 && /[\p{L}\p{M}\p{N}_)\]]/u.test(input[position - 1])) {
|
|
96
100
|
return false;
|
|
97
101
|
}
|
|
98
102
|
return (
|
|
@@ -627,17 +627,31 @@ describe('fromInterchangeNode: control flow', () => {
|
|
|
627
627
|
expect(node.roles.get('condition')).toEqual({ type: 'literal', value: 'running' });
|
|
628
628
|
});
|
|
629
629
|
|
|
630
|
-
|
|
630
|
+
// The event is the EVENT, not an until condition: `until "done"` would test a
|
|
631
|
+
// truthy string and end at once.
|
|
632
|
+
it('converts RepeatNode (until event), keeping the event and where it listens', () => {
|
|
631
633
|
const node = fromInterchangeNode({
|
|
632
634
|
type: 'repeat',
|
|
633
635
|
untilEvent: 'done',
|
|
636
|
+
untilEventTarget: { type: 'selector', value: '#b' },
|
|
634
637
|
body: [],
|
|
635
638
|
});
|
|
636
639
|
|
|
637
640
|
expect(node.kind).toBe('loop');
|
|
638
641
|
const loop = node as { loopVariant: string };
|
|
639
642
|
expect(loop.loopVariant).toBe('until');
|
|
640
|
-
expect(node.roles.get('
|
|
643
|
+
expect(node.roles.get('loopType')).toEqual({ type: 'literal', value: 'until-event' });
|
|
644
|
+
expect(node.roles.get('event')).toEqual({ type: 'literal', value: 'done' });
|
|
645
|
+
expect(node.roles.get('source')).toMatchObject({ type: 'selector', value: '#b' });
|
|
646
|
+
expect(node.roles.has('condition')).toBe(false);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it.each([
|
|
650
|
+
{ type: 'repeat', count: 3, indexName: 'i', body: [] },
|
|
651
|
+
{ type: 'while', condition: { type: 'identifier', value: 'x' }, indexName: 'i', body: [] },
|
|
652
|
+
])('keeps `index i` on a $type loop', interchange => {
|
|
653
|
+
const loop = fromInterchangeNode(interchange) as { indexVariable?: string };
|
|
654
|
+
expect(loop.indexVariable).toBe('i');
|
|
641
655
|
});
|
|
642
656
|
|
|
643
657
|
it('converts ForEachNode', () => {
|
|
@@ -216,13 +216,26 @@ function convertRepeat(node: INode): SemanticNode {
|
|
|
216
216
|
variant = 'while';
|
|
217
217
|
roles.condition = convertValue(node.whileCondition as INode);
|
|
218
218
|
} else if (typeof node.untilEvent === 'string') {
|
|
219
|
+
// `repeat until event e from t`: the event and where it listens, as the
|
|
220
|
+
// semantic parser holds them, plus the exact form `loopVariant` folds into
|
|
221
|
+
// `until`. The event name was the CONDITION, an `until "e"` loop that a
|
|
222
|
+
// truthy string ends at once.
|
|
219
223
|
variant = 'until';
|
|
220
|
-
roles.
|
|
224
|
+
roles.loopType = createLiteral('until-event');
|
|
225
|
+
roles.event = createLiteral(node.untilEvent);
|
|
226
|
+
if (node.untilEventTarget && typeof node.untilEventTarget === 'object') {
|
|
227
|
+
roles.source = convertValue(node.untilEventTarget as INode);
|
|
228
|
+
}
|
|
221
229
|
} else {
|
|
222
230
|
variant = 'forever';
|
|
223
231
|
}
|
|
224
232
|
|
|
225
|
-
return createLoopNode('repeat', roles, variant, body);
|
|
233
|
+
return createLoopNode('repeat', roles, variant, body, undefined, indexNameOf(node));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** `index i`, which every loop form can name. */
|
|
237
|
+
function indexNameOf(node: INode): string | undefined {
|
|
238
|
+
return typeof node.indexName === 'string' ? node.indexName : undefined;
|
|
226
239
|
}
|
|
227
240
|
|
|
228
241
|
function convertForEach(node: INode): SemanticNode {
|
|
@@ -235,9 +248,8 @@ function convertForEach(node: INode): SemanticNode {
|
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
const itemName = typeof node.itemName === 'string' ? node.itemName : undefined;
|
|
238
|
-
const indexName = typeof node.indexName === 'string' ? node.indexName : undefined;
|
|
239
251
|
|
|
240
|
-
return createLoopNode('repeat', roles, 'for', body, itemName,
|
|
252
|
+
return createLoopNode('repeat', roles, 'for', body, itemName, indexNameOf(node));
|
|
241
253
|
}
|
|
242
254
|
|
|
243
255
|
function convertWhile(node: INode): SemanticNode {
|
|
@@ -249,7 +261,7 @@ function convertWhile(node: INode): SemanticNode {
|
|
|
249
261
|
roles.condition = convertValue(node.condition as INode);
|
|
250
262
|
}
|
|
251
263
|
|
|
252
|
-
return createLoopNode('repeat', roles, 'while', body);
|
|
264
|
+
return createLoopNode('repeat', roles, 'while', body, undefined, indexNameOf(node));
|
|
253
265
|
}
|
|
254
266
|
|
|
255
267
|
// =============================================================================
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `semanticNodeToRuntimeAST` writes a loop in the shape core's parser builds:
|
|
3
|
+
* the form in `modifiers.loopType`, the operands in `for`/`in`, `times`,
|
|
4
|
+
* `while`/`until`, `event`/`from` and `index`, the body the one block.
|
|
5
|
+
*
|
|
6
|
+
* It wrote `args: [count]` with `loopVariant` and `body` beside it, a shape
|
|
7
|
+
* core's `repeat` has not read since the slot migration, so every loop through
|
|
8
|
+
* `evalLSE` threw "repeat command requires a loop type". core's
|
|
9
|
+
* `lse/loop-lse.test.ts` RUNS these; this pins the shape where it is written.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { semanticNodeToRuntimeAST } from './to-runtime-ast';
|
|
13
|
+
import { parseExplicit } from './explicit-parser';
|
|
14
|
+
import { createLoopNode, createLiteral, createCommandNode } from '../core/types';
|
|
15
|
+
|
|
16
|
+
const slot = (value: string) => ({ type: 'string', value });
|
|
17
|
+
const TOGGLE = '[toggle patient:.a]';
|
|
18
|
+
|
|
19
|
+
function loop(explicit: string): Record<string, unknown> {
|
|
20
|
+
return semanticNodeToRuntimeAST(parseExplicit(explicit)) as unknown as Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('a loop becomes core’s slot-shaped repeat', () => {
|
|
24
|
+
it('counted', () => {
|
|
25
|
+
expect(loop(`[repeat quantity:3 loop-variant:times loop-body:${TOGGLE}]`)).toEqual({
|
|
26
|
+
type: 'command',
|
|
27
|
+
name: 'repeat',
|
|
28
|
+
args: [{ type: 'block', commands: [expect.objectContaining({ name: 'toggle' })] }],
|
|
29
|
+
modifiers: { loopType: slot('times'), times: { type: 'literal', value: 3 } },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('for-in', () => {
|
|
34
|
+
expect(
|
|
35
|
+
loop(`[for source:.i variable:p loop-variant:for loop-body:${TOGGLE}]`).modifiers
|
|
36
|
+
).toEqual({
|
|
37
|
+
loopType: slot('for'),
|
|
38
|
+
for: slot('p'),
|
|
39
|
+
in: { type: 'selector', value: '.i' },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it.each(['while', 'until'])('%s', form => {
|
|
44
|
+
const modifiers = loop(`[repeat condition:done loop-variant:${form} loop-body:${TOGGLE}]`)
|
|
45
|
+
.modifiers as Record<string, unknown>;
|
|
46
|
+
expect(modifiers.loopType).toEqual(slot(form));
|
|
47
|
+
expect(modifiers[form]).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// `loopVariant` folds `until event` into `until`; the loopType role keeps it.
|
|
51
|
+
it('until event, from a target', () => {
|
|
52
|
+
expect(
|
|
53
|
+
loop(
|
|
54
|
+
`[repeat loop-variant:until loopType:until-event event:stop source:#b loop-body:${TOGGLE}]`
|
|
55
|
+
).modifiers
|
|
56
|
+
).toEqual({
|
|
57
|
+
loopType: slot('until-event'),
|
|
58
|
+
event: slot('stop'),
|
|
59
|
+
from: { type: 'selector', value: '#b' },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('forever', () => {
|
|
64
|
+
expect(loop(`[repeat loop-variant:forever loop-body:${TOGGLE}]`).modifiers).toEqual({
|
|
65
|
+
loopType: slot('forever'),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('`index i`', () => {
|
|
70
|
+
const modifiers = loop(
|
|
71
|
+
`[repeat quantity:2 loop-variant:times index-variable:i loop-body:${TOGGLE}]`
|
|
72
|
+
).modifiers as Record<string, unknown>;
|
|
73
|
+
expect(modifiers.index).toEqual(slot('i'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('reads a count from `patient`, as the old shape did', () => {
|
|
77
|
+
const node = createLoopNode('repeat', { patient: createLiteral(4, 'number') }, 'times', [
|
|
78
|
+
createCommandNode('toggle', {}),
|
|
79
|
+
]);
|
|
80
|
+
const ast = semanticNodeToRuntimeAST(node) as unknown as Record<string, unknown>;
|
|
81
|
+
expect((ast.modifiers as Record<string, unknown>).times).toEqual({
|
|
82
|
+
type: 'literal',
|
|
83
|
+
value: 4,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('an if becomes core’s args-shaped if', () => {
|
|
89
|
+
it('with its then- and else-blocks', () => {
|
|
90
|
+
const ast = semanticNodeToRuntimeAST(
|
|
91
|
+
parseExplicit('[if condition:true then:[toggle patient:.a] else:[toggle patient:.b]]')
|
|
92
|
+
) as unknown as Record<string, unknown>;
|
|
93
|
+
expect(ast).toEqual({
|
|
94
|
+
type: 'command',
|
|
95
|
+
name: 'if',
|
|
96
|
+
args: [
|
|
97
|
+
{ type: 'literal', value: true },
|
|
98
|
+
{ type: 'block', commands: [expect.objectContaining({ name: 'toggle' })] },
|
|
99
|
+
{ type: 'block', commands: [expect.objectContaining({ name: 'toggle' })] },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('without an else-block when there is none', () => {
|
|
105
|
+
const ast = semanticNodeToRuntimeAST(parseExplicit(`[if condition:true then:${TOGGLE}]`));
|
|
106
|
+
expect((ast as unknown as { args: unknown[] }).args).toHaveLength(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('with empty args when it has no condition, so core refuses it', () => {
|
|
110
|
+
const ast = semanticNodeToRuntimeAST(parseExplicit(`[if then:${TOGGLE}]`));
|
|
111
|
+
expect((ast as unknown as { args: unknown[] }).args).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/ir/to-runtime-ast.ts
CHANGED
|
@@ -128,29 +128,97 @@ function convertCommand(node: SemanticNode): RuntimeCommandNode {
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/** A body as core's parser holds one: a block of commands. */
|
|
132
|
+
function block(nodes: readonly SemanticNode[] | undefined): RuntimeASTNode {
|
|
133
|
+
return { type: 'block', commands: (nodes ?? []).map(semanticNodeToRuntimeAST) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* An `if` as core's parser builds it: `args: [condition, then-block,
|
|
138
|
+
* else-block?]`. This wrote the branches beside `args: [condition]`, so every
|
|
139
|
+
* `if` through `evalLSE` threw "if command requires "then" branch with
|
|
140
|
+
* commands".
|
|
141
|
+
*/
|
|
131
142
|
function convertConditional(node: ConditionalSemanticNode): RuntimeASTNode {
|
|
143
|
+
const name = node.action === 'unless' ? 'unless' : 'if';
|
|
132
144
|
const conditionValue = node.roles.get('condition');
|
|
145
|
+
// No condition is malformed: empty args make core say so.
|
|
146
|
+
if (!conditionValue) return { type: 'command', name, args: [] };
|
|
133
147
|
return {
|
|
134
148
|
type: 'command',
|
|
135
|
-
name
|
|
136
|
-
args:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
name,
|
|
150
|
+
args: [
|
|
151
|
+
semanticValueToAST(conditionValue),
|
|
152
|
+
block(node.thenBranch),
|
|
153
|
+
...(node.elseBranch && node.elseBranch.length > 0 ? [block(node.elseBranch)] : []),
|
|
154
|
+
],
|
|
140
155
|
};
|
|
141
156
|
}
|
|
142
157
|
|
|
158
|
+
/** The loop forms core's `repeat` runs: its parser's `loopType`. */
|
|
159
|
+
const LOOP_FORMS = new Set(['for', 'times', 'while', 'until', 'until-event', 'forever']);
|
|
160
|
+
|
|
161
|
+
/** The text of a name-like value: a loop form, an event name. */
|
|
162
|
+
function valueText(value: SemanticValue | undefined): string | undefined {
|
|
163
|
+
if (!value) return undefined;
|
|
164
|
+
if (value.type === 'literal') return String(value.value);
|
|
165
|
+
if (value.type === 'expression') return value.raw;
|
|
166
|
+
if (value.type === 'reference' || value.type === 'selector') return value.value;
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A loop as core's parser builds it (Arc 3 step 3): the form in
|
|
172
|
+
* `modifiers.loopType`, the operands in `for`/`in`, `times`, `while`/`until`,
|
|
173
|
+
* `event`/`from` and `index`, the body the one positional block.
|
|
174
|
+
*
|
|
175
|
+
* This emitted `args: [count]` with the variant and body beside it, a shape
|
|
176
|
+
* `repeat` has not read since the slot migration, so every loop through
|
|
177
|
+
* `evalLSE` / `compileLSE` threw "repeat command requires a loop type". The
|
|
178
|
+
* form comes from a `loopType` role when it names one, since it keeps the
|
|
179
|
+
* exact form (`until-event`) that `loopVariant` folds into `until`.
|
|
180
|
+
*/
|
|
143
181
|
function convertLoop(node: LoopSemanticNode): RuntimeASTNode {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
182
|
+
const text = (value: string): RuntimeASTNode => ({ type: 'string', value });
|
|
183
|
+
const operand = (role: string): RuntimeASTNode | undefined => {
|
|
184
|
+
const value = node.roles.get(role);
|
|
185
|
+
return value ? semanticValueToAST(value) : undefined;
|
|
186
|
+
};
|
|
187
|
+
const declared = valueText(node.roles.get('loopType'));
|
|
188
|
+
const form =
|
|
189
|
+
node.action === 'for'
|
|
190
|
+
? 'for'
|
|
191
|
+
: declared && LOOP_FORMS.has(declared)
|
|
192
|
+
? declared
|
|
193
|
+
: (node.loopVariant ?? 'forever');
|
|
194
|
+
|
|
195
|
+
const modifiers: Record<string, RuntimeASTNode> = { loopType: text(form) };
|
|
196
|
+
const slot = (name: string, value: RuntimeASTNode | undefined) => {
|
|
197
|
+
if (value) modifiers[name] = value;
|
|
153
198
|
};
|
|
199
|
+
switch (form) {
|
|
200
|
+
case 'for':
|
|
201
|
+
if (node.loopVariable) slot('for', text(node.loopVariable));
|
|
202
|
+
slot('in', operand('source'));
|
|
203
|
+
break;
|
|
204
|
+
case 'times':
|
|
205
|
+
// `patient` and `count` are what this read before the slot shape.
|
|
206
|
+
slot('times', operand('quantity') ?? operand('patient') ?? operand('count'));
|
|
207
|
+
break;
|
|
208
|
+
case 'while':
|
|
209
|
+
case 'until':
|
|
210
|
+
slot(form, operand('condition'));
|
|
211
|
+
break;
|
|
212
|
+
case 'until-event': {
|
|
213
|
+
const event = valueText(node.roles.get('event'));
|
|
214
|
+
if (event) slot('event', text(event));
|
|
215
|
+
slot('from', operand('source'));
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (node.indexVariable) slot('index', text(node.indexVariable));
|
|
220
|
+
|
|
221
|
+
return { type: 'command', name: 'repeat', args: [block(node.body)], modifiers };
|
|
154
222
|
}
|
|
155
223
|
|
|
156
224
|
/**
|