@lokascript/framework 2.3.1 → 2.4.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 +29 -516
- package/dist/api/index.js.map +1 -1
- package/dist/core/index.js +20 -128
- package/dist/core/index.js.map +1 -1
- package/dist/core/pattern-matching/index.js +19 -12
- package/dist/core/pattern-matching/index.js.map +1 -1
- package/dist/core/tokenization/index.js.map +1 -1
- package/dist/core/types.d.ts +7 -354
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +21 -129
- package/dist/core/types.js.map +1 -1
- package/dist/generation/diagnostics.d.ts +5 -115
- package/dist/generation/diagnostics.d.ts.map +1 -1
- package/dist/generation/index.js +20 -77
- package/dist/generation/index.js.map +1 -1
- package/dist/index.cjs +189 -1458
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +108 -1347
- package/dist/index.js.map +1 -1
- package/dist/interfaces/value-extractor.d.ts +20 -0
- package/dist/interfaces/value-extractor.d.ts.map +1 -1
- package/dist/ir/explicit-parser.d.ts +2 -83
- package/dist/ir/explicit-parser.d.ts.map +1 -1
- package/dist/ir/explicit-renderer.d.ts +2 -31
- package/dist/ir/explicit-renderer.d.ts.map +1 -1
- package/dist/ir/index.js +89 -1260
- package/dist/ir/index.js.map +1 -1
- package/dist/ir/protocol-json.d.ts +2 -70
- package/dist/ir/protocol-json.d.ts.map +1 -1
- package/dist/ir/references.d.ts +2 -16
- package/dist/ir/references.d.ts.map +1 -1
- package/dist/ir/types.d.ts +2 -148
- package/dist/ir/types.d.ts.map +1 -1
- package/dist/parsing/index.js +19 -12
- package/dist/parsing/index.js.map +1 -1
- package/dist/schema/command-schema.d.ts +3 -92
- package/dist/schema/command-schema.d.ts.map +1 -1
- package/dist/schema/index.js +3 -20
- package/dist/schema/index.js.map +1 -1
- package/dist/testing/index.js +1066 -712
- package/dist/testing/index.js.map +1 -1
- package/package.json +6 -2
- package/src/core/types.ts +71 -600
- package/src/generation/diagnostics.ts +12 -233
- package/src/interfaces/value-extractor.ts +32 -0
- package/src/ir/explicit-parser.ts +12 -792
- package/src/ir/explicit-renderer.ts +2 -195
- package/src/ir/from-interchange.test.ts +91 -4
- package/src/ir/from-interchange.ts +59 -0
- package/src/ir/protocol-json.test.ts +16 -7
- package/src/ir/protocol-json.ts +9 -748
- package/src/ir/references.ts +2 -30
- package/src/ir/types.ts +18 -190
- package/src/schema/command-schema.ts +3 -134
|
@@ -1,793 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Explicit Syntax Parser
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* @annotation(value) [command ...]
|
|
15
|
-
* #!lse 1.2\n[cmd1 ...]\n[cmd2 ...]
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type {
|
|
19
|
-
SemanticNode,
|
|
20
|
-
SemanticValue,
|
|
21
|
-
SemanticRole,
|
|
22
|
-
ActionType,
|
|
23
|
-
LoopVariant,
|
|
24
|
-
Annotation,
|
|
25
|
-
LSEEnvelope,
|
|
26
|
-
CompoundSemanticNode,
|
|
27
|
-
} from '../core/types';
|
|
28
|
-
import {
|
|
29
|
-
createCommandNode,
|
|
30
|
-
createEventHandlerNode,
|
|
31
|
-
createConditionalNode,
|
|
32
|
-
createLoopNode,
|
|
33
|
-
createCompoundNode,
|
|
34
|
-
createSelector,
|
|
35
|
-
createLiteral,
|
|
36
|
-
createReference,
|
|
37
|
-
createFlag,
|
|
38
|
-
} from '../core/types';
|
|
39
|
-
import type { Diagnostic } from '../generation/diagnostics';
|
|
40
|
-
import type { ParseExplicitOptions } from './types';
|
|
41
|
-
import { isValidReference, DEFAULT_REFERENCES } from './references';
|
|
42
|
-
|
|
43
|
-
/** Structural role names whose bracket-enclosed values may be nested commands. */
|
|
44
|
-
export const STRUCTURAL_ROLES = new Set([
|
|
45
|
-
'body',
|
|
46
|
-
'then',
|
|
47
|
-
'else',
|
|
48
|
-
'condition',
|
|
49
|
-
'loop-body',
|
|
50
|
-
'variable',
|
|
51
|
-
'catch', // v1.2: try/catch/finally
|
|
52
|
-
'finally', // v1.2: try/catch/finally
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Checks whether a bracket-enclosed value is a nested command (vs. an attribute selector).
|
|
57
|
-
* A value starting with `[` is a nested command if the inner content (after stripping
|
|
58
|
-
* outer `[]`) contains at least one ASCII space or `:` at bracket-depth 0.
|
|
59
|
-
*/
|
|
60
|
-
function isNestedCommand(value: string): boolean {
|
|
61
|
-
const inner = value.slice(1, -1);
|
|
62
|
-
let depth = 0;
|
|
63
|
-
for (const ch of inner) {
|
|
64
|
-
if (ch === '[') depth++;
|
|
65
|
-
else if (ch === ']') depth--;
|
|
66
|
-
else if (depth === 0 && (ch === ' ' || ch === ':')) return true;
|
|
67
|
-
}
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// =============================================================================
|
|
72
|
-
// Explicit Syntax Parser
|
|
73
|
-
// =============================================================================
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Parse explicit bracket syntax into a semantic node.
|
|
77
|
-
*
|
|
78
|
-
* When `options.schemaLookup` is provided, validates roles against the
|
|
79
|
-
* command's schema. When omitted, accepts any role names.
|
|
80
|
-
*/
|
|
81
|
-
export function parseExplicit(input: string, options: ParseExplicitOptions = {}): SemanticNode {
|
|
82
|
-
const trimmed = input.trim();
|
|
83
|
-
const collect = options.collectDiagnostics === true;
|
|
84
|
-
const diagnostics: Diagnostic[] = [];
|
|
85
|
-
|
|
86
|
-
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
|
|
87
|
-
throw new Error('Explicit syntax must be wrapped in brackets: [command role:value ...]');
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const content = trimmed.slice(1, -1).trim();
|
|
91
|
-
if (!content) {
|
|
92
|
-
throw new Error('Empty explicit statement');
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const tokens = tokenizeExplicit(content);
|
|
96
|
-
if (tokens.length === 0) {
|
|
97
|
-
throw new Error('No command specified in explicit statement');
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const command = tokens[0].toLowerCase() as ActionType;
|
|
101
|
-
const roles = new Map<SemanticRole, SemanticValue>();
|
|
102
|
-
const refSet = options.referenceSet ?? DEFAULT_REFERENCES;
|
|
103
|
-
|
|
104
|
-
// Look up schema for role validation (undefined = skip validation)
|
|
105
|
-
const schema = options.schemaLookup?.getSchema(command);
|
|
106
|
-
const validRoleNames = schema ? new Set(schema.roles.map(r => r.role)) : null;
|
|
107
|
-
|
|
108
|
-
// Parse role:value pairs and flags
|
|
109
|
-
for (let i = 1; i < tokens.length; i++) {
|
|
110
|
-
const token = tokens[i];
|
|
111
|
-
|
|
112
|
-
// Boolean flag: +flag-name or ~flag-name
|
|
113
|
-
if (token.startsWith('+') || token.startsWith('~')) {
|
|
114
|
-
const enabled = token.startsWith('+');
|
|
115
|
-
const flagName = token.slice(1);
|
|
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
|
-
}
|
|
126
|
-
throw new Error(`Empty flag name: "${token}"`);
|
|
127
|
-
}
|
|
128
|
-
if (validRoleNames && !validRoleNames.has(flagName as SemanticRole)) {
|
|
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
|
-
}
|
|
142
|
-
throw new Error(
|
|
143
|
-
`Unknown flag "${flagName}" for command "${command}". Valid roles: ${roleList}`
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
roles.set(flagName as SemanticRole, createFlag(flagName, enabled));
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const colonIndex = token.indexOf(':');
|
|
151
|
-
|
|
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
|
-
}
|
|
162
|
-
throw new Error(`Invalid role format: "${token}". Expected role:value or +flag`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const roleName = token.slice(0, colonIndex);
|
|
166
|
-
|
|
167
|
-
// Validate role name against schema (skip for structural roles)
|
|
168
|
-
if (
|
|
169
|
-
validRoleNames &&
|
|
170
|
-
!STRUCTURAL_ROLES.has(roleName) &&
|
|
171
|
-
!validRoleNames.has(roleName as SemanticRole)
|
|
172
|
-
) {
|
|
173
|
-
const roleList = [...validRoleNames].join(', ');
|
|
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
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const role = roleName as SemanticRole;
|
|
191
|
-
const valueStr = token.slice(colonIndex + 1);
|
|
192
|
-
|
|
193
|
-
// Handle nested explicit syntax for structural roles (body, then, else, condition, loop-body, variable)
|
|
194
|
-
if (STRUCTURAL_ROLES.has(roleName) && valueStr.startsWith('[') && isNestedCommand(valueStr)) {
|
|
195
|
-
const nestedEnd = findMatchingBracket(token, colonIndex + 1);
|
|
196
|
-
const nestedSyntax = token.slice(colonIndex + 1, nestedEnd + 1);
|
|
197
|
-
roles.set(role, { type: 'expression', raw: nestedSyntax });
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const value = parseExplicitValue(valueStr, refSet);
|
|
202
|
-
roles.set(role, value);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Validate required roles are present (skip for event handlers — handled below)
|
|
206
|
-
if (schema && command !== 'on') {
|
|
207
|
-
for (const roleSpec of schema.roles) {
|
|
208
|
-
if (roleSpec.required && !roles.has(roleSpec.role as SemanticRole) && !roleSpec.default) {
|
|
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
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
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
|
-
|
|
231
|
-
// Build appropriate node type based on action name
|
|
232
|
-
switch (command) {
|
|
233
|
-
case 'on': {
|
|
234
|
-
const eventValue = roles.get('event');
|
|
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
|
-
}
|
|
250
|
-
throw new Error('Event handler requires event role: [on event:click ...]');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Parse body if present
|
|
254
|
-
const body = extractStructuralBody(roles, 'body', options);
|
|
255
|
-
roles.delete('body' as SemanticRole);
|
|
256
|
-
|
|
257
|
-
return attachDiagnostics(
|
|
258
|
-
createEventHandlerNode('on', roles, body, {
|
|
259
|
-
sourceLanguage: 'explicit',
|
|
260
|
-
})
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
case 'if': {
|
|
265
|
-
// Extract structural roles for conditional
|
|
266
|
-
const thenBranch = extractStructuralBody(roles, 'then', options);
|
|
267
|
-
const elseBranch = extractStructuralBody(roles, 'else', options);
|
|
268
|
-
roles.delete('then' as SemanticRole);
|
|
269
|
-
roles.delete('else' as SemanticRole);
|
|
270
|
-
|
|
271
|
-
return attachDiagnostics(
|
|
272
|
-
createConditionalNode(
|
|
273
|
-
command,
|
|
274
|
-
roles,
|
|
275
|
-
thenBranch,
|
|
276
|
-
elseBranch.length > 0 ? elseBranch : undefined,
|
|
277
|
-
{
|
|
278
|
-
sourceLanguage: 'explicit',
|
|
279
|
-
}
|
|
280
|
-
)
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
case 'repeat': {
|
|
285
|
-
// Extract loop structural roles
|
|
286
|
-
const loopBody = extractStructuralBody(roles, 'loop-body', options);
|
|
287
|
-
const loopVariantValue = roles.get('loop-variant' as SemanticRole);
|
|
288
|
-
const variableValue = roles.get('variable' as SemanticRole);
|
|
289
|
-
const indexVariableValue = roles.get('index-variable' as SemanticRole);
|
|
290
|
-
|
|
291
|
-
roles.delete('loop-body' as SemanticRole);
|
|
292
|
-
roles.delete('loop-variant' as SemanticRole);
|
|
293
|
-
roles.delete('variable' as SemanticRole);
|
|
294
|
-
roles.delete('index-variable' as SemanticRole);
|
|
295
|
-
|
|
296
|
-
// Determine loop variant: explicit > inferred from roles
|
|
297
|
-
let variant: LoopVariant;
|
|
298
|
-
if (
|
|
299
|
-
loopVariantValue &&
|
|
300
|
-
loopVariantValue.type === 'literal' &&
|
|
301
|
-
typeof loopVariantValue.value === 'string'
|
|
302
|
-
) {
|
|
303
|
-
variant = loopVariantValue.value as LoopVariant;
|
|
304
|
-
} else if (roles.has('quantity' as SemanticRole)) {
|
|
305
|
-
variant = 'times';
|
|
306
|
-
} else if (roles.has('source' as SemanticRole)) {
|
|
307
|
-
variant = 'for';
|
|
308
|
-
} else if (roles.has('condition' as SemanticRole)) {
|
|
309
|
-
variant = 'while';
|
|
310
|
-
} else {
|
|
311
|
-
variant = 'forever';
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const loopVariable =
|
|
315
|
-
variableValue?.type === 'literal' && typeof variableValue.value === 'string'
|
|
316
|
-
? variableValue.value
|
|
317
|
-
: variableValue?.type === 'expression'
|
|
318
|
-
? variableValue.raw
|
|
319
|
-
: undefined;
|
|
320
|
-
const indexVariable =
|
|
321
|
-
indexVariableValue?.type === 'literal' && typeof indexVariableValue.value === 'string'
|
|
322
|
-
? indexVariableValue.value
|
|
323
|
-
: undefined;
|
|
324
|
-
|
|
325
|
-
return attachDiagnostics(
|
|
326
|
-
createLoopNode(command, roles, variant, loopBody, loopVariable, indexVariable, {
|
|
327
|
-
sourceLanguage: 'explicit',
|
|
328
|
-
})
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
default:
|
|
333
|
-
return attachDiagnostics(
|
|
334
|
-
createCommandNode(command, roles, {
|
|
335
|
-
sourceLanguage: 'explicit',
|
|
336
|
-
})
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Check if input is explicit bracket syntax.
|
|
343
|
-
*/
|
|
344
|
-
export function isExplicitSyntax(input: string): boolean {
|
|
345
|
-
const trimmed = input.trim();
|
|
346
|
-
return trimmed.startsWith('[') && trimmed.endsWith(']');
|
|
347
|
-
}
|
|
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
|
-
|
|
637
|
-
// =============================================================================
|
|
638
|
-
// Internal Helpers
|
|
639
|
-
// =============================================================================
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
* Extract and parse a structural role (body, then, else, loop-body) into SemanticNode[].
|
|
643
|
-
* Returns an empty array if the role is not present.
|
|
644
|
-
*/
|
|
645
|
-
function extractStructuralBody(
|
|
646
|
-
roles: Map<SemanticRole, SemanticValue>,
|
|
647
|
-
roleName: string,
|
|
648
|
-
options: ParseExplicitOptions
|
|
649
|
-
): SemanticNode[] {
|
|
650
|
-
const value = roles.get(roleName as SemanticRole);
|
|
651
|
-
if (!value) return [];
|
|
652
|
-
if (value.type === 'expression') {
|
|
653
|
-
return [parseExplicit(value.raw, options)];
|
|
654
|
-
}
|
|
655
|
-
return [];
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/**
|
|
659
|
-
* Infer selectorKind from a CSS selector string.
|
|
660
|
-
*/
|
|
661
|
-
function inferSelectorKind(
|
|
662
|
-
value: string
|
|
663
|
-
): 'id' | 'class' | 'attribute' | 'element' | 'complex' | undefined {
|
|
664
|
-
if (!value) return undefined;
|
|
665
|
-
if (value.startsWith('#')) return 'id';
|
|
666
|
-
if (value.startsWith('.')) return 'class';
|
|
667
|
-
if (value.startsWith('[')) return 'attribute';
|
|
668
|
-
if (value.startsWith('<') || value.startsWith('*')) return 'element';
|
|
669
|
-
if (/[>+~ ]/.test(value) && value.length > 1) return 'complex';
|
|
670
|
-
return undefined;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/**
|
|
674
|
-
* Tokenize explicit syntax content (space-separated, respecting quotes and brackets).
|
|
675
|
-
*/
|
|
676
|
-
function tokenizeExplicit(content: string): string[] {
|
|
677
|
-
const tokens: string[] = [];
|
|
678
|
-
let current = '';
|
|
679
|
-
let inString = false;
|
|
680
|
-
let stringChar = '';
|
|
681
|
-
let bracketDepth = 0;
|
|
682
|
-
|
|
683
|
-
for (let i = 0; i < content.length; i++) {
|
|
684
|
-
const char = content[i];
|
|
685
|
-
|
|
686
|
-
if (inString) {
|
|
687
|
-
current += char;
|
|
688
|
-
if (char === stringChar && content[i - 1] !== '\\') {
|
|
689
|
-
inString = false;
|
|
690
|
-
}
|
|
691
|
-
continue;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
if (char === '"' || char === "'") {
|
|
695
|
-
inString = true;
|
|
696
|
-
stringChar = char;
|
|
697
|
-
current += char;
|
|
698
|
-
continue;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
if (char === '[') {
|
|
702
|
-
bracketDepth++;
|
|
703
|
-
current += char;
|
|
704
|
-
continue;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
if (char === ']') {
|
|
708
|
-
bracketDepth--;
|
|
709
|
-
current += char;
|
|
710
|
-
continue;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
if (char === ' ' && bracketDepth === 0) {
|
|
714
|
-
if (current) {
|
|
715
|
-
tokens.push(current);
|
|
716
|
-
current = '';
|
|
717
|
-
}
|
|
718
|
-
continue;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
current += char;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
if (current) {
|
|
725
|
-
tokens.push(current);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
return tokens;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/**
|
|
732
|
-
* Parse a value string into a SemanticValue.
|
|
733
|
-
*/
|
|
734
|
-
function parseExplicitValue(valueStr: string, referenceSet: ReadonlySet<string>): SemanticValue {
|
|
735
|
-
// CSS selector
|
|
736
|
-
if (
|
|
737
|
-
valueStr.startsWith('#') ||
|
|
738
|
-
valueStr.startsWith('.') ||
|
|
739
|
-
valueStr.startsWith('[') ||
|
|
740
|
-
valueStr.startsWith('@') ||
|
|
741
|
-
valueStr.startsWith('*')
|
|
742
|
-
) {
|
|
743
|
-
return createSelector(valueStr, inferSelectorKind(valueStr));
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// String literal
|
|
747
|
-
if (valueStr.startsWith('"') || valueStr.startsWith("'")) {
|
|
748
|
-
const inner = valueStr.slice(1, -1);
|
|
749
|
-
return createLiteral(inner, 'string');
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// Boolean
|
|
753
|
-
if (valueStr === 'true') return createLiteral(true, 'boolean');
|
|
754
|
-
if (valueStr === 'false') return createLiteral(false, 'boolean');
|
|
755
|
-
|
|
756
|
-
// Reference
|
|
757
|
-
const lowerRef = valueStr.toLowerCase();
|
|
758
|
-
if (isValidReference(lowerRef, referenceSet)) {
|
|
759
|
-
return createReference(lowerRef);
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// Number (possibly with duration suffix)
|
|
763
|
-
const numMatch = valueStr.match(/^(-?\d+(?:\.\d+)?)(ms|s|m|h)?$/);
|
|
764
|
-
if (numMatch) {
|
|
765
|
-
const num = parseFloat(numMatch[1]);
|
|
766
|
-
const suffix = numMatch[2];
|
|
767
|
-
if (suffix) {
|
|
768
|
-
return createLiteral(valueStr, 'duration');
|
|
769
|
-
}
|
|
770
|
-
return createLiteral(num, 'number');
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// Default to untyped string literal (no dataType — avoids spurious quoting on round-trip).
|
|
774
|
-
// Only explicitly-quoted values (handled above) get dataType: 'string'.
|
|
775
|
-
return createLiteral(valueStr);
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Find the matching closing bracket.
|
|
780
|
-
*/
|
|
781
|
-
function findMatchingBracket(str: string, start: number): number {
|
|
782
|
-
let depth = 0;
|
|
783
|
-
|
|
784
|
-
for (let i = start; i < str.length; i++) {
|
|
785
|
-
if (str[i] === '[') depth++;
|
|
786
|
-
else if (str[i] === ']') {
|
|
787
|
-
depth--;
|
|
788
|
-
if (depth === 0) return i;
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
return str.length - 1;
|
|
793
|
-
}
|
|
2
|
+
* Explicit Syntax Parser — re-exported from @lokascript/intent
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
STRUCTURAL_ROLES,
|
|
7
|
+
parseExplicit,
|
|
8
|
+
isExplicitSyntax,
|
|
9
|
+
parseCompound,
|
|
10
|
+
parseDocument,
|
|
11
|
+
isCompoundSyntax,
|
|
12
|
+
isDocumentSyntax,
|
|
13
|
+
} from '@lokascript/intent';
|