@lokascript/framework 2.1.0 → 2.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/dist/api/index.js +111 -24
- package/dist/api/index.js.map +1 -1
- package/dist/core/index.js +133 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/pattern-matching/index.js.map +1 -1
- package/dist/core/tokenization/index.js +133 -0
- package/dist/core/tokenization/index.js.map +1 -1
- package/dist/core/tokenization/morphology/base-normalizer.d.ts +94 -0
- package/dist/core/tokenization/morphology/base-normalizer.d.ts.map +1 -0
- package/dist/core/tokenization/morphology/index.d.ts +3 -1
- package/dist/core/tokenization/morphology/index.d.ts.map +1 -1
- package/dist/core/types.d.ts +3 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/generation/index.js.map +1 -1
- package/dist/index.cjs +564 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +556 -46
- package/dist/index.js.map +1 -1
- package/dist/ir/explicit-parser.d.ts +62 -7
- package/dist/ir/explicit-parser.d.ts.map +1 -1
- package/dist/ir/explicit-renderer.d.ts +18 -1
- package/dist/ir/explicit-renderer.d.ts.map +1 -1
- package/dist/ir/index.d.ts +4 -2
- package/dist/ir/index.d.ts.map +1 -1
- package/dist/ir/index.js +423 -46
- package/dist/ir/index.js.map +1 -1
- package/dist/ir/protocol-json.d.ts.map +1 -1
- package/dist/ir/to-runtime-ast.d.ts +53 -0
- package/dist/ir/to-runtime-ast.d.ts.map +1 -0
- package/dist/ir/types.d.ts +14 -4
- package/dist/ir/types.d.ts.map +1 -1
- package/dist/parsing/index.js.map +1 -1
- package/dist/testing/index.js +8671 -8261
- package/dist/testing/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/tokenization/morphology/base-normalizer.ts +249 -0
- package/src/core/tokenization/morphology/index.ts +3 -1
- package/src/core/types.ts +4 -2
- package/src/index.ts +7 -0
- package/src/ir/conformance.test.ts +120 -0
- package/src/ir/explicit-parser.test.ts +307 -1
- package/src/ir/explicit-parser.ts +403 -29
- package/src/ir/explicit-renderer.test.ts +87 -2
- package/src/ir/explicit-renderer.ts +57 -5
- package/src/ir/index.ts +13 -2
- package/src/ir/protocol-json.test.ts +4 -2
- package/src/ir/protocol-json.ts +30 -24
- package/src/ir/to-runtime-ast.ts +203 -0
- package/src/ir/types.ts +14 -4
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* Parses the universal [command role:value ...] bracket syntax.
|
|
5
5
|
* Domain-agnostic — schema validation is injectable via ParseExplicitOptions.
|
|
6
6
|
*
|
|
7
|
+
* Three parsing levels matching the ABNF grammar:
|
|
8
|
+
* parseDocument() → parseCompound() → parseExplicit()
|
|
9
|
+
* (multi-line) (chain ops) (single bracket)
|
|
10
|
+
*
|
|
7
11
|
* Syntax:
|
|
8
12
|
* [command role1:value1 role2:value2 ...]
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* [
|
|
12
|
-
* [put patient:"hello" destination:#output]
|
|
13
|
-
* [on event:click body:[toggle patient:.active]]
|
|
14
|
-
* [select patient:name source:users condition:age>25]
|
|
13
|
+
* [cmd1 ...] then [cmd2 ...]
|
|
14
|
+
* @annotation(value) [command ...]
|
|
15
|
+
* #!lse 1.2\n[cmd1 ...]\n[cmd2 ...]
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import type {
|
|
@@ -20,17 +21,22 @@ import type {
|
|
|
20
21
|
SemanticRole,
|
|
21
22
|
ActionType,
|
|
22
23
|
LoopVariant,
|
|
24
|
+
Annotation,
|
|
25
|
+
LSEEnvelope,
|
|
26
|
+
CompoundSemanticNode,
|
|
23
27
|
} from '../core/types';
|
|
24
28
|
import {
|
|
25
29
|
createCommandNode,
|
|
26
30
|
createEventHandlerNode,
|
|
27
31
|
createConditionalNode,
|
|
28
32
|
createLoopNode,
|
|
33
|
+
createCompoundNode,
|
|
29
34
|
createSelector,
|
|
30
35
|
createLiteral,
|
|
31
36
|
createReference,
|
|
32
37
|
createFlag,
|
|
33
38
|
} from '../core/types';
|
|
39
|
+
import type { Diagnostic } from '../generation/diagnostics';
|
|
34
40
|
import type { ParseExplicitOptions } from './types';
|
|
35
41
|
import { isValidReference, DEFAULT_REFERENCES } from './references';
|
|
36
42
|
|
|
@@ -74,6 +80,8 @@ function isNestedCommand(value: string): boolean {
|
|
|
74
80
|
*/
|
|
75
81
|
export function parseExplicit(input: string, options: ParseExplicitOptions = {}): SemanticNode {
|
|
76
82
|
const trimmed = input.trim();
|
|
83
|
+
const collect = options.collectDiagnostics === true;
|
|
84
|
+
const diagnostics: Diagnostic[] = [];
|
|
77
85
|
|
|
78
86
|
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
|
|
79
87
|
throw new Error('Explicit syntax must be wrapped in brackets: [command role:value ...]');
|
|
@@ -106,10 +114,31 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
|
|
|
106
114
|
const enabled = token.startsWith('+');
|
|
107
115
|
const flagName = token.slice(1);
|
|
108
116
|
if (!flagName) {
|
|
117
|
+
if (collect) {
|
|
118
|
+
diagnostics.push({
|
|
119
|
+
severity: 'error',
|
|
120
|
+
code: 'EMPTY_FLAG',
|
|
121
|
+
message: `Empty flag name: "${token}"`,
|
|
122
|
+
source: 'schema',
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
109
126
|
throw new Error(`Empty flag name: "${token}"`);
|
|
110
127
|
}
|
|
111
128
|
if (validRoleNames && !validRoleNames.has(flagName as SemanticRole)) {
|
|
112
129
|
const roleList = [...validRoleNames].join(', ');
|
|
130
|
+
if (collect) {
|
|
131
|
+
diagnostics.push({
|
|
132
|
+
severity: 'error',
|
|
133
|
+
code: 'UNKNOWN_FLAG',
|
|
134
|
+
message: `Unknown flag "${flagName}" for command "${command}"`,
|
|
135
|
+
source: 'schema',
|
|
136
|
+
suggestions: [...validRoleNames].map(r => `+${r}`),
|
|
137
|
+
});
|
|
138
|
+
// Still add the flag so the node is usable
|
|
139
|
+
roles.set(flagName as SemanticRole, createFlag(flagName, enabled));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
113
142
|
throw new Error(
|
|
114
143
|
`Unknown flag "${flagName}" for command "${command}". Valid roles: ${roleList}`
|
|
115
144
|
);
|
|
@@ -121,6 +150,15 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
|
|
|
121
150
|
const colonIndex = token.indexOf(':');
|
|
122
151
|
|
|
123
152
|
if (colonIndex === -1) {
|
|
153
|
+
if (collect) {
|
|
154
|
+
diagnostics.push({
|
|
155
|
+
severity: 'error',
|
|
156
|
+
code: 'INVALID_ROLE_FORMAT',
|
|
157
|
+
message: `Invalid role format: "${token}". Expected role:value or +flag`,
|
|
158
|
+
source: 'schema',
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
124
162
|
throw new Error(`Invalid role format: "${token}". Expected role:value or +flag`);
|
|
125
163
|
}
|
|
126
164
|
|
|
@@ -133,9 +171,20 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
|
|
|
133
171
|
!validRoleNames.has(roleName as SemanticRole)
|
|
134
172
|
) {
|
|
135
173
|
const roleList = [...validRoleNames].join(', ');
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
174
|
+
if (collect) {
|
|
175
|
+
diagnostics.push({
|
|
176
|
+
severity: 'error',
|
|
177
|
+
code: 'UNKNOWN_ROLE',
|
|
178
|
+
message: `Unknown role "${roleName}" for command "${command}"`,
|
|
179
|
+
source: 'schema',
|
|
180
|
+
suggestions: [...validRoleNames],
|
|
181
|
+
});
|
|
182
|
+
// Still parse and add the role so the node is usable
|
|
183
|
+
} else {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Unknown role "${roleName}" for command "${command}". Valid roles: ${roleList}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
139
188
|
}
|
|
140
189
|
|
|
141
190
|
const role = roleName as SemanticRole;
|
|
@@ -157,18 +206,47 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
|
|
|
157
206
|
if (schema && command !== 'on') {
|
|
158
207
|
for (const roleSpec of schema.roles) {
|
|
159
208
|
if (roleSpec.required && !roles.has(roleSpec.role as SemanticRole) && !roleSpec.default) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
209
|
+
if (collect) {
|
|
210
|
+
diagnostics.push({
|
|
211
|
+
severity: 'error',
|
|
212
|
+
code: 'MISSING_REQUIRED_ROLE',
|
|
213
|
+
message: `Missing required role "${roleSpec.role}" for command "${command}": ${roleSpec.description}`,
|
|
214
|
+
source: 'schema',
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Missing required role "${roleSpec.role}" for command "${command}": ${roleSpec.description}`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
163
221
|
}
|
|
164
222
|
}
|
|
165
223
|
}
|
|
166
224
|
|
|
225
|
+
// Helper: attach diagnostics to a node if any were collected
|
|
226
|
+
const attachDiagnostics = (node: SemanticNode): SemanticNode => {
|
|
227
|
+
if (diagnostics.length === 0) return node;
|
|
228
|
+
return { ...node, diagnostics };
|
|
229
|
+
};
|
|
230
|
+
|
|
167
231
|
// Build appropriate node type based on action name
|
|
168
232
|
switch (command) {
|
|
169
233
|
case 'on': {
|
|
170
234
|
const eventValue = roles.get('event');
|
|
171
235
|
if (!eventValue) {
|
|
236
|
+
if (collect) {
|
|
237
|
+
diagnostics.push({
|
|
238
|
+
severity: 'error',
|
|
239
|
+
code: 'MISSING_EVENT_ROLE',
|
|
240
|
+
message: 'Event handler requires event role: [on event:click ...]',
|
|
241
|
+
source: 'schema',
|
|
242
|
+
});
|
|
243
|
+
// Return a stub event handler with diagnostics
|
|
244
|
+
return attachDiagnostics(
|
|
245
|
+
createEventHandlerNode('on', roles, [], {
|
|
246
|
+
sourceLanguage: 'explicit',
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
}
|
|
172
250
|
throw new Error('Event handler requires event role: [on event:click ...]');
|
|
173
251
|
}
|
|
174
252
|
|
|
@@ -176,9 +254,11 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
|
|
|
176
254
|
const body = extractStructuralBody(roles, 'body', options);
|
|
177
255
|
roles.delete('body' as SemanticRole);
|
|
178
256
|
|
|
179
|
-
return
|
|
180
|
-
|
|
181
|
-
|
|
257
|
+
return attachDiagnostics(
|
|
258
|
+
createEventHandlerNode('on', roles, body, {
|
|
259
|
+
sourceLanguage: 'explicit',
|
|
260
|
+
})
|
|
261
|
+
);
|
|
182
262
|
}
|
|
183
263
|
|
|
184
264
|
case 'if': {
|
|
@@ -188,14 +268,16 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
|
|
|
188
268
|
roles.delete('then' as SemanticRole);
|
|
189
269
|
roles.delete('else' as SemanticRole);
|
|
190
270
|
|
|
191
|
-
return
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
271
|
+
return attachDiagnostics(
|
|
272
|
+
createConditionalNode(
|
|
273
|
+
command,
|
|
274
|
+
roles,
|
|
275
|
+
thenBranch,
|
|
276
|
+
elseBranch.length > 0 ? elseBranch : undefined,
|
|
277
|
+
{
|
|
278
|
+
sourceLanguage: 'explicit',
|
|
279
|
+
}
|
|
280
|
+
)
|
|
199
281
|
);
|
|
200
282
|
}
|
|
201
283
|
|
|
@@ -240,15 +322,19 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
|
|
|
240
322
|
? indexVariableValue.value
|
|
241
323
|
: undefined;
|
|
242
324
|
|
|
243
|
-
return
|
|
244
|
-
|
|
245
|
-
|
|
325
|
+
return attachDiagnostics(
|
|
326
|
+
createLoopNode(command, roles, variant, loopBody, loopVariable, indexVariable, {
|
|
327
|
+
sourceLanguage: 'explicit',
|
|
328
|
+
})
|
|
329
|
+
);
|
|
246
330
|
}
|
|
247
331
|
|
|
248
332
|
default:
|
|
249
|
-
return
|
|
250
|
-
|
|
251
|
-
|
|
333
|
+
return attachDiagnostics(
|
|
334
|
+
createCommandNode(command, roles, {
|
|
335
|
+
sourceLanguage: 'explicit',
|
|
336
|
+
})
|
|
337
|
+
);
|
|
252
338
|
}
|
|
253
339
|
}
|
|
254
340
|
|
|
@@ -260,6 +346,294 @@ export function isExplicitSyntax(input: string): boolean {
|
|
|
260
346
|
return trimmed.startsWith('[') && trimmed.endsWith(']');
|
|
261
347
|
}
|
|
262
348
|
|
|
349
|
+
// =============================================================================
|
|
350
|
+
// Compound Statement Parsing
|
|
351
|
+
// =============================================================================
|
|
352
|
+
|
|
353
|
+
/** Chain operators per ABNF: "then" / "and" / "async" / "sequential" / "|>" */
|
|
354
|
+
const CHAIN_KEYWORDS = new Set(['then', 'and', 'async', 'sequential']);
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Split input into bracket-command segments separated by chain operators.
|
|
358
|
+
* Returns segments and the chain operator found (first one wins for mixed).
|
|
359
|
+
*/
|
|
360
|
+
function splitCompoundSegments(input: string): {
|
|
361
|
+
segments: string[];
|
|
362
|
+
chainType: CompoundSemanticNode['chainType'] | null;
|
|
363
|
+
} {
|
|
364
|
+
const segments: string[] = [];
|
|
365
|
+
let chainType: CompoundSemanticNode['chainType'] | null = null;
|
|
366
|
+
let current = '';
|
|
367
|
+
let bracketDepth = 0;
|
|
368
|
+
let inString = false;
|
|
369
|
+
let stringChar = '';
|
|
370
|
+
|
|
371
|
+
for (let i = 0; i < input.length; i++) {
|
|
372
|
+
const char = input[i];
|
|
373
|
+
|
|
374
|
+
if (inString) {
|
|
375
|
+
current += char;
|
|
376
|
+
if (char === stringChar && input[i - 1] !== '\\') {
|
|
377
|
+
inString = false;
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (char === '"' || char === "'") {
|
|
383
|
+
inString = true;
|
|
384
|
+
stringChar = char;
|
|
385
|
+
current += char;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (char === '[') {
|
|
390
|
+
bracketDepth++;
|
|
391
|
+
current += char;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (char === ']') {
|
|
396
|
+
bracketDepth--;
|
|
397
|
+
current += char;
|
|
398
|
+
|
|
399
|
+
// At depth 0 after closing bracket, check for chain operator
|
|
400
|
+
if (bracketDepth === 0) {
|
|
401
|
+
const rest = input.slice(i + 1);
|
|
402
|
+
// Check for |> pipe operator
|
|
403
|
+
const pipeMatch = rest.match(/^\s*\|>\s*/);
|
|
404
|
+
if (pipeMatch) {
|
|
405
|
+
segments.push(current.trim());
|
|
406
|
+
current = '';
|
|
407
|
+
chainType ??= 'pipe';
|
|
408
|
+
i += pipeMatch[0].length;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
// Check for keyword chain operators
|
|
412
|
+
const keywordMatch = rest.match(/^\s+(then|and|async|sequential)\s+/);
|
|
413
|
+
if (keywordMatch && CHAIN_KEYWORDS.has(keywordMatch[1])) {
|
|
414
|
+
segments.push(current.trim());
|
|
415
|
+
current = '';
|
|
416
|
+
chainType ??= keywordMatch[1] as CompoundSemanticNode['chainType'];
|
|
417
|
+
i += keywordMatch[0].length;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
current += char;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (current.trim()) {
|
|
428
|
+
segments.push(current.trim());
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { segments, chainType };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Parse a compound statement (one or more bracket commands chained with operators).
|
|
436
|
+
*
|
|
437
|
+
* ABNF: compound-stmt = bracket-cmd *( chain-op bracket-cmd )
|
|
438
|
+
*
|
|
439
|
+
* Single commands pass through to parseExplicit(). Multiple commands produce
|
|
440
|
+
* a CompoundSemanticNode.
|
|
441
|
+
*
|
|
442
|
+
* Also handles annotations (@name or @name(value)) before the first bracket.
|
|
443
|
+
*
|
|
444
|
+
* @example
|
|
445
|
+
* ```typescript
|
|
446
|
+
* // Single command — passthrough
|
|
447
|
+
* parseCompound('[toggle patient:.active]')
|
|
448
|
+
*
|
|
449
|
+
* // Compound statement
|
|
450
|
+
* parseCompound('[add patient:.loading] then [fetch source:/api/data]')
|
|
451
|
+
*
|
|
452
|
+
* // With annotation
|
|
453
|
+
* parseCompound('@timeout(5s) [fetch source:/api/users]')
|
|
454
|
+
* ```
|
|
455
|
+
*/
|
|
456
|
+
export function parseCompound(input: string, options?: ParseExplicitOptions): SemanticNode {
|
|
457
|
+
const trimmed = input.trim();
|
|
458
|
+
|
|
459
|
+
// Extract annotations before the first bracket
|
|
460
|
+
const { annotations, remainder } = extractAnnotations(trimmed);
|
|
461
|
+
|
|
462
|
+
const { segments, chainType } = splitCompoundSegments(remainder);
|
|
463
|
+
|
|
464
|
+
let node: SemanticNode;
|
|
465
|
+
if (segments.length <= 1) {
|
|
466
|
+
// Single command — delegate to parseExplicit
|
|
467
|
+
node = parseExplicit(segments[0] || remainder, options);
|
|
468
|
+
} else {
|
|
469
|
+
// Multiple commands — create compound node
|
|
470
|
+
const statements = segments.map(seg => parseExplicit(seg, options));
|
|
471
|
+
node = createCompoundNode(statements, chainType ?? 'sequential');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Attach annotations if any
|
|
475
|
+
if (annotations.length > 0) {
|
|
476
|
+
return { ...node, annotations };
|
|
477
|
+
}
|
|
478
|
+
return node;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// =============================================================================
|
|
482
|
+
// Annotation Parsing
|
|
483
|
+
// =============================================================================
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Extract @name and @name(value) annotations from before the first bracket.
|
|
487
|
+
* Per ABNF, annotations precede the compound statement on the same line.
|
|
488
|
+
*/
|
|
489
|
+
function extractAnnotations(input: string): { annotations: Annotation[]; remainder: string } {
|
|
490
|
+
const annotations: Annotation[] = [];
|
|
491
|
+
let pos = 0;
|
|
492
|
+
|
|
493
|
+
while (pos < input.length) {
|
|
494
|
+
// Skip whitespace
|
|
495
|
+
while (pos < input.length && (input[pos] === ' ' || input[pos] === '\t')) {
|
|
496
|
+
pos++;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// If we hit '[' or end of string, we're done with annotations
|
|
500
|
+
if (pos >= input.length || input[pos] !== '@') {
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Parse @name
|
|
505
|
+
pos++; // skip @
|
|
506
|
+
const nameStart = pos;
|
|
507
|
+
while (pos < input.length && /[A-Za-z0-9\-_]/.test(input[pos])) {
|
|
508
|
+
pos++;
|
|
509
|
+
}
|
|
510
|
+
const name = input.slice(nameStart, pos);
|
|
511
|
+
if (!name) break;
|
|
512
|
+
|
|
513
|
+
// Check for (value)
|
|
514
|
+
let value: string | undefined;
|
|
515
|
+
if (pos < input.length && input[pos] === '(') {
|
|
516
|
+
pos++; // skip (
|
|
517
|
+
const valueStart = pos;
|
|
518
|
+
// Handle quoted strings inside annotation values
|
|
519
|
+
if (pos < input.length && (input[pos] === '"' || input[pos] === "'")) {
|
|
520
|
+
const quote = input[pos];
|
|
521
|
+
pos++; // skip opening quote
|
|
522
|
+
while (pos < input.length && input[pos] !== quote) {
|
|
523
|
+
if (input[pos] === '\\') pos++; // skip escape
|
|
524
|
+
pos++;
|
|
525
|
+
}
|
|
526
|
+
pos++; // skip closing quote
|
|
527
|
+
value = input.slice(valueStart + 1, pos - 1); // strip quotes
|
|
528
|
+
} else {
|
|
529
|
+
// Plain value chars: ALPHA / DIGIT / "-" / "_" / "." / "/" / ":"
|
|
530
|
+
while (pos < input.length && input[pos] !== ')') {
|
|
531
|
+
pos++;
|
|
532
|
+
}
|
|
533
|
+
value = input.slice(valueStart, pos);
|
|
534
|
+
}
|
|
535
|
+
if (pos < input.length && input[pos] === ')') {
|
|
536
|
+
pos++; // skip )
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
annotations.push(value !== undefined ? { name, value } : { name });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { annotations, remainder: input.slice(pos).trim() };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// =============================================================================
|
|
547
|
+
// Document Parsing
|
|
548
|
+
// =============================================================================
|
|
549
|
+
|
|
550
|
+
/** Version header regex: #!lse <version> */
|
|
551
|
+
const VERSION_HEADER_RE = /^#!lse\s+(\d+\.\d+(?:\.\d+)?)\s*$/;
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Parse a multi-line LSE document into an LSEEnvelope.
|
|
555
|
+
*
|
|
556
|
+
* ABNF: document = [ version-header LF ] *( line LF ) [ line ]
|
|
557
|
+
*
|
|
558
|
+
* Handles version headers (#!lse 1.2), comments (// or #), blank lines,
|
|
559
|
+
* and statement lines (one compound statement per line).
|
|
560
|
+
*
|
|
561
|
+
* @example
|
|
562
|
+
* ```typescript
|
|
563
|
+
* const envelope = parseDocument(`
|
|
564
|
+
* #!lse 1.2
|
|
565
|
+
* // Toggle active state
|
|
566
|
+
* [toggle patient:.active]
|
|
567
|
+
* [add patient:.highlight]
|
|
568
|
+
* `);
|
|
569
|
+
* // envelope.lseVersion === '1.2'
|
|
570
|
+
* // envelope.nodes.length === 2
|
|
571
|
+
* ```
|
|
572
|
+
*/
|
|
573
|
+
export function parseDocument(input: string, options?: ParseExplicitOptions): LSEEnvelope {
|
|
574
|
+
const lines = input.split('\n');
|
|
575
|
+
let lseVersion = '1.0';
|
|
576
|
+
const nodes: SemanticNode[] = [];
|
|
577
|
+
let startLine = 0;
|
|
578
|
+
|
|
579
|
+
// Check for version header on first non-blank line
|
|
580
|
+
for (let i = 0; i < lines.length; i++) {
|
|
581
|
+
const trimmed = lines[i].trim();
|
|
582
|
+
if (!trimmed) continue; // skip leading blank lines
|
|
583
|
+
const versionMatch = trimmed.match(VERSION_HEADER_RE);
|
|
584
|
+
if (versionMatch) {
|
|
585
|
+
lseVersion = versionMatch[1];
|
|
586
|
+
startLine = i + 1;
|
|
587
|
+
}
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Parse remaining lines
|
|
592
|
+
for (let i = startLine; i < lines.length; i++) {
|
|
593
|
+
const trimmed = lines[i].trim();
|
|
594
|
+
|
|
595
|
+
// Skip blank lines
|
|
596
|
+
if (!trimmed) continue;
|
|
597
|
+
|
|
598
|
+
// Skip comments (// or #, but NOT #! which is version header)
|
|
599
|
+
if (trimmed.startsWith('//') || (trimmed.startsWith('#') && !trimmed.startsWith('#!'))) {
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Parse statement line
|
|
604
|
+
nodes.push(parseCompound(trimmed, options));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return { lseVersion, nodes };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// =============================================================================
|
|
611
|
+
// Detection Helpers
|
|
612
|
+
// =============================================================================
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Check if input contains compound chaining (multiple bracket commands
|
|
616
|
+
* with chain operators at bracket-depth 0).
|
|
617
|
+
*/
|
|
618
|
+
export function isCompoundSyntax(input: string): boolean {
|
|
619
|
+
const { segments } = splitCompoundSegments(input.trim());
|
|
620
|
+
return segments.length > 1;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Check if input is a multi-line LSE document (contains newlines,
|
|
625
|
+
* version header, or comment lines).
|
|
626
|
+
*/
|
|
627
|
+
export function isDocumentSyntax(input: string): boolean {
|
|
628
|
+
const trimmed = input.trim();
|
|
629
|
+
if (trimmed.includes('\n')) return true;
|
|
630
|
+
if (VERSION_HEADER_RE.test(trimmed)) return true;
|
|
631
|
+
if (trimmed.startsWith('//') || (trimmed.startsWith('#') && !trimmed.startsWith('#!'))) {
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
|
|
263
637
|
// =============================================================================
|
|
264
638
|
// Internal Helpers
|
|
265
639
|
// =============================================================================
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { renderExplicit } from './explicit-renderer';
|
|
3
|
-
import { parseExplicit } from './explicit-parser';
|
|
2
|
+
import { renderExplicit, renderDocument } from './explicit-renderer';
|
|
3
|
+
import { parseExplicit, parseCompound } from './explicit-parser';
|
|
4
4
|
import {
|
|
5
5
|
createCommandNode,
|
|
6
6
|
createSelector,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
createEventHandlerNode,
|
|
11
11
|
createCompoundNode,
|
|
12
12
|
} from '../core/types';
|
|
13
|
+
import type { SemanticNode, LSEEnvelope } from '../core/types';
|
|
13
14
|
|
|
14
15
|
describe('renderExplicit', () => {
|
|
15
16
|
it('renders a basic command', () => {
|
|
@@ -142,3 +143,87 @@ describe('round-trip: parse → render', () => {
|
|
|
142
143
|
});
|
|
143
144
|
}
|
|
144
145
|
});
|
|
146
|
+
|
|
147
|
+
describe('renderExplicit — annotations', () => {
|
|
148
|
+
it('renders a node with a single annotation', () => {
|
|
149
|
+
const node: SemanticNode = {
|
|
150
|
+
...createCommandNode('fetch', {
|
|
151
|
+
source: createLiteral('/api/users', 'string'),
|
|
152
|
+
}),
|
|
153
|
+
annotations: [{ name: 'timeout', value: '5s' }],
|
|
154
|
+
};
|
|
155
|
+
expect(renderExplicit(node)).toBe('@timeout(5s) [fetch source:"/api/users"]');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('renders annotation without value', () => {
|
|
159
|
+
const node: SemanticNode = {
|
|
160
|
+
...createCommandNode('toggle', {
|
|
161
|
+
patient: createSelector('.active'),
|
|
162
|
+
}),
|
|
163
|
+
annotations: [{ name: 'deprecated' }],
|
|
164
|
+
};
|
|
165
|
+
expect(renderExplicit(node)).toBe('@deprecated [toggle patient:.active]');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('renders multiple annotations preserving order', () => {
|
|
169
|
+
const node: SemanticNode = {
|
|
170
|
+
...createCommandNode('fetch', {
|
|
171
|
+
source: createLiteral('/api/data', 'string'),
|
|
172
|
+
}),
|
|
173
|
+
annotations: [
|
|
174
|
+
{ name: 'retry', value: '3' },
|
|
175
|
+
{ name: 'timeout', value: '10s' },
|
|
176
|
+
],
|
|
177
|
+
};
|
|
178
|
+
const result = renderExplicit(node);
|
|
179
|
+
expect(result).toBe('@retry(3) @timeout(10s) [fetch source:"/api/data"]');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('renders no prefix when annotations are absent', () => {
|
|
183
|
+
const node = createCommandNode('toggle', {
|
|
184
|
+
patient: createSelector('.active'),
|
|
185
|
+
});
|
|
186
|
+
expect(renderExplicit(node)).toBe('[toggle patient:.active]');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('annotation round-trip: render → parse → render', () => {
|
|
190
|
+
const node: SemanticNode = {
|
|
191
|
+
...createCommandNode('fetch', {
|
|
192
|
+
source: createLiteral('/api/users', 'string'),
|
|
193
|
+
}),
|
|
194
|
+
annotations: [{ name: 'timeout', value: '5s' }],
|
|
195
|
+
};
|
|
196
|
+
const rendered = renderExplicit(node);
|
|
197
|
+
const reparsed = parseCompound(rendered);
|
|
198
|
+
const rerendered = renderExplicit(reparsed);
|
|
199
|
+
expect(rerendered).toBe(rendered);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('renderDocument', () => {
|
|
204
|
+
it('renders an envelope with version header', () => {
|
|
205
|
+
const envelope: LSEEnvelope = {
|
|
206
|
+
lseVersion: '1.2',
|
|
207
|
+
nodes: [
|
|
208
|
+
createCommandNode('toggle', { patient: createSelector('.active') }),
|
|
209
|
+
createCommandNode('add', { patient: createSelector('.highlight') }),
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
const result = renderDocument(envelope);
|
|
213
|
+
expect(result).toBe('#!lse 1.2\n[toggle patient:.active]\n[add patient:.highlight]');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('omits version header for version 1.0', () => {
|
|
217
|
+
const envelope: LSEEnvelope = {
|
|
218
|
+
lseVersion: '1.0',
|
|
219
|
+
nodes: [createCommandNode('toggle', { patient: createSelector('.active') })],
|
|
220
|
+
};
|
|
221
|
+
const result = renderDocument(envelope);
|
|
222
|
+
expect(result).toBe('[toggle patient:.active]');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('renders empty envelope', () => {
|
|
226
|
+
const envelope: LSEEnvelope = { lseVersion: '1.2', nodes: [] };
|
|
227
|
+
expect(renderDocument(envelope)).toBe('#!lse 1.2');
|
|
228
|
+
});
|
|
229
|
+
});
|