@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.
Files changed (54) hide show
  1. package/dist/api/index.js +29 -516
  2. package/dist/api/index.js.map +1 -1
  3. package/dist/core/index.js +20 -128
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/pattern-matching/index.js +19 -12
  6. package/dist/core/pattern-matching/index.js.map +1 -1
  7. package/dist/core/tokenization/index.js.map +1 -1
  8. package/dist/core/types.d.ts +7 -354
  9. package/dist/core/types.d.ts.map +1 -1
  10. package/dist/core/types.js +21 -129
  11. package/dist/core/types.js.map +1 -1
  12. package/dist/generation/diagnostics.d.ts +5 -115
  13. package/dist/generation/diagnostics.d.ts.map +1 -1
  14. package/dist/generation/index.js +20 -77
  15. package/dist/generation/index.js.map +1 -1
  16. package/dist/index.cjs +189 -1458
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.js +108 -1347
  19. package/dist/index.js.map +1 -1
  20. package/dist/interfaces/value-extractor.d.ts +20 -0
  21. package/dist/interfaces/value-extractor.d.ts.map +1 -1
  22. package/dist/ir/explicit-parser.d.ts +2 -83
  23. package/dist/ir/explicit-parser.d.ts.map +1 -1
  24. package/dist/ir/explicit-renderer.d.ts +2 -31
  25. package/dist/ir/explicit-renderer.d.ts.map +1 -1
  26. package/dist/ir/index.js +89 -1260
  27. package/dist/ir/index.js.map +1 -1
  28. package/dist/ir/protocol-json.d.ts +2 -70
  29. package/dist/ir/protocol-json.d.ts.map +1 -1
  30. package/dist/ir/references.d.ts +2 -16
  31. package/dist/ir/references.d.ts.map +1 -1
  32. package/dist/ir/types.d.ts +2 -148
  33. package/dist/ir/types.d.ts.map +1 -1
  34. package/dist/parsing/index.js +19 -12
  35. package/dist/parsing/index.js.map +1 -1
  36. package/dist/schema/command-schema.d.ts +3 -92
  37. package/dist/schema/command-schema.d.ts.map +1 -1
  38. package/dist/schema/index.js +3 -20
  39. package/dist/schema/index.js.map +1 -1
  40. package/dist/testing/index.js +1066 -712
  41. package/dist/testing/index.js.map +1 -1
  42. package/package.json +6 -2
  43. package/src/core/types.ts +71 -600
  44. package/src/generation/diagnostics.ts +12 -233
  45. package/src/interfaces/value-extractor.ts +32 -0
  46. package/src/ir/explicit-parser.ts +12 -792
  47. package/src/ir/explicit-renderer.ts +2 -195
  48. package/src/ir/from-interchange.test.ts +91 -4
  49. package/src/ir/from-interchange.ts +59 -0
  50. package/src/ir/protocol-json.test.ts +16 -7
  51. package/src/ir/protocol-json.ts +9 -748
  52. package/src/ir/references.ts +2 -30
  53. package/src/ir/types.ts +18 -190
  54. package/src/schema/command-schema.ts +3 -134
@@ -1,793 +1,13 @@
1
1
  /**
2
- * Explicit Syntax Parser
3
- *
4
- * Parses the universal [command role:value ...] bracket syntax.
5
- * Domain-agnostic — schema validation is injectable via ParseExplicitOptions.
6
- *
7
- * Three parsing levels matching the ABNF grammar:
8
- * parseDocument() → parseCompound() → parseExplicit()
9
- * (multi-line) (chain ops) (single bracket)
10
- *
11
- * Syntax:
12
- * [command role1:value1 role2:value2 ...]
13
- * [cmd1 ...] then [cmd2 ...]
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';