@lokascript/framework 3.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lokascript/framework",
3
- "version": "3.1.0",
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.1.0"
99
+ "@lokascript/intent": "^3.2.0"
100
100
  },
101
101
  "devDependencies": {
102
- "@lokascript/domains": "^2.11.1",
103
- "@types/node": "^26.1.2",
104
- "@vitest/coverage-v8": "^4.0.17",
102
+ "@lokascript/domains": "^3.0.0",
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": "^4.1.5"
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
- if (char === "'" && position > 0 && /[\p{L}\p{N}_)\]]/u.test(input[position - 1])) {
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
- it('converts RepeatNode (until)', () => {
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('condition')).toEqual({ type: 'literal', value: 'done' });
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.condition = createLiteral(node.untilEvent);
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, indexName);
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
+ });
@@ -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: 'if',
136
- args: conditionValue ? [semanticValueToAST(conditionValue)] : [],
137
- condition: conditionValue ? semanticValueToAST(conditionValue) : undefined,
138
- thenBranch: (node.thenBranch ?? []).map(semanticNodeToRuntimeAST),
139
- elseBranch: (node.elseBranch ?? []).map(semanticNodeToRuntimeAST),
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 countValue = node.roles.get('patient') || node.roles.get('count');
145
- return {
146
- type: 'command',
147
- name: 'repeat',
148
- args: countValue ? [semanticValueToAST(countValue)] : [],
149
- loopVariant: node.loopVariant ?? 'forever',
150
- body: (node.body ?? []).map(semanticNodeToRuntimeAST),
151
- loopVariable: node.loopVariable,
152
- indexVariable: node.indexVariable,
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
  /**