sommark 4.2.0 → 4.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.
@@ -0,0 +1,2521 @@
1
+ /**
2
+ * Token Types in SomMark.
3
+ * These represent the basic lexical atoms identified by the lexer.
4
+ *
5
+ * @constant {Object}
6
+ * @property {string} OPEN_BRACKET - '[' char.
7
+ * @property {string} CLOSE_BRACKET - ']' char.
8
+ * @property {string} END_KEYWORD - 'end' value.
9
+ * @property {string} IDENTIFIER - Block or inline name (e.g. 'Person', 'import', '$use-module').
10
+ * @property {string} EQUAL - '=' char.
11
+ * @property {string} VALUE - Data values. Encapsulates Quoted Strings ("...") and Prefix Layers (js{}, p{}).
12
+ * @property {string} TEXT - Plain unformatted text content.
13
+ * @property {string} THIN_ARROW - '->' sequence.
14
+ * @property {string} OPEN_PAREN - '(' char.
15
+ * @property {string} CLOSE_PAREN - ')' char.
16
+ * @property {string} OPEN_AT - '@_' sequence (At-Block start).
17
+ * @property {string} CLOSE_AT - '_@' sequence (At-Header end).
18
+ * @property {string} COLON - ':' char.
19
+ * @property {string} COMMA - ',' char.
20
+ * @property {string} SEMICOLON - ';' char (At-Block separator).
21
+ * @property {string} COMMENT - '#' comments.
22
+ * @property {string} COMMENT_BLOCK - '###' comments.
23
+ * @property {string} ESCAPE - '\' char. Used for literalizing structural chars like '\"' or '\['.
24
+ * @property {string} QUOTE - '"' delimiter.
25
+ * @property {string} EXCLAMATION_MARK - '!' char.
26
+ * @property {string} IMPORT - 'import' keyword.
27
+ * @property {string} USE_MODULE - '$use-module' keyword.
28
+ * @property {string} PREFIX_JS - 'js{}' prefix layer.
29
+ * @property {string} PREFIX_P - 'p{}' placeholder layer.
30
+ * @property {string} PREFIX_V - 'v{}' local variable layer.
31
+ * @property {string} EOF - End of File indicator.
32
+ */
33
+ const TOKEN_TYPES = {
34
+ OPEN_BRACKET: "OPEN_BRACKET",
35
+ CLOSE_BRACKET: "CLOSE_BRACKET",
36
+ END_KEYWORD: "END_KEYWORD",
37
+ IMPORT: "IMPORT",
38
+ USE_MODULE: "USE_MODULE",
39
+ IDENTIFIER: "IDENTIFIER",
40
+ EQUAL: "EQUAL",
41
+ VALUE: "VALUE",
42
+ QUOTE: "QUOTE",
43
+ PREFIX_JS: "PREFIX_JS",
44
+ PREFIX_P: "PREFIX_P",
45
+ PREFIX_V: "PREFIX_V",
46
+ TEXT: "TEXT",
47
+ THIN_ARROW: "THIN_ARROW",
48
+ OPEN_PAREN: "OPEN_PAREN",
49
+ CLOSE_PAREN: "CLOSE_PAREN",
50
+ OPEN_AT: "OPEN_AT",
51
+ CLOSE_AT: "CLOSE_AT",
52
+ COLON: "COLON",
53
+ COMMA: "COMMA",
54
+ SEMICOLON: "SEMICOLON",
55
+ COMMENT: "COMMENT",
56
+ COMMENT_BLOCK: "COMMENT_BLOCK",
57
+ ESCAPE: "ESCAPE",
58
+ EXCLAMATION_MARK: "EXCLAMATION_MARK",
59
+ SLOT_KEYWORD: "SLOT_KEYWORD",
60
+ KEY: "KEY",
61
+ WHITESPACE: "WHITESPACE",
62
+ STATIC_KEYWORD: "STATIC_KEYWORD",
63
+ RUNTIME_KEYWORD: "RUNTIME_KEYWORD",
64
+ LOGIC: "LOGIC",
65
+ FOR_EACH: "FOR_EACH",
66
+ EOF: "EOF"
67
+ };
68
+
69
+ /**
70
+ * Looks at an item in a list or string without moving your current position.
71
+ * You can look ahead or behind by using a positive or negative offset.
72
+ *
73
+ * @param {Array|string} input - The list or string to check.
74
+ * @param {number} index - Your current spot in the list.
75
+ * @param {number} offset - How many spots to look ahead or behind.
76
+ * @returns {any|null} - The item you found, or null if it is out of range.
77
+ */
78
+ function peek(input, index, offset) {
79
+ if (input === null || index < 0 || offset < -index) {
80
+ return null;
81
+ }
82
+ if (index + offset < input.length) {
83
+ if (input[index + offset] !== undefined) {
84
+ return input[index + offset];
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * These labels identify different parts of the code (like blocks or text)
92
+ * so the system knows how to handle them.
93
+ */
94
+ const BLOCK = "Block",
95
+ TEXT = "Text",
96
+ INLINE = "Inline",
97
+ ATBLOCK = "AtBlock",
98
+ COMMENT = "Comment",
99
+ COMMENT_BLOCK = "CommentBlock",
100
+ IMPORT = "Import",
101
+ USE_MODULE = "$use-module",
102
+ SLOT = "Slot",
103
+ STATIC_LOGIC = "StaticLogic",
104
+ RUNTIME_LOGIC = "RuntimeLogic",
105
+ FOR_EACH = "ForEach";
106
+
107
+ /**
108
+ * Names for symbols used to separate parts of the code (like commas and colons).
109
+ */
110
+ const SEMICOLON = "Semicolon",
111
+ BLOCKCOMMA = "Block-comma",
112
+ ATBLOCKCOMMA = "Atblock-comma",
113
+ INLINECOMMA = "Inline-comma",
114
+ BLOCKCOLON = "Block-colon",
115
+ ATBLOCKCOLON = "Atblock-colon",
116
+ INLINECOLON = "Inline-colon";
117
+
118
+ /**
119
+ * These names are used in error messages to tell you exactly which part
120
+ * of your code has a mistake.
121
+ */
122
+ const block_id = "Block Identifier",
123
+ block_value = "Block Value",
124
+ block_key = "Block Key",
125
+ block_end = "Block end",
126
+ inline_id = "Inline Identifier",
127
+ inline_text = "Inline Text",
128
+ at_id = "At Identifier",
129
+ at_value = "At Value",
130
+ atblock_key = "AtBlock Key",
131
+ at_end = "Atblock End",
132
+ /** Reserved keyword for closing blocks */
133
+ end_keyword = "end",
134
+ slot_keyword = "slot",
135
+ for_each_keyword = "for-each";
136
+
137
+ var labels = /*#__PURE__*/Object.freeze({
138
+ __proto__: null,
139
+ ATBLOCK: ATBLOCK,
140
+ ATBLOCKCOLON: ATBLOCKCOLON,
141
+ ATBLOCKCOMMA: ATBLOCKCOMMA,
142
+ BLOCK: BLOCK,
143
+ BLOCKCOLON: BLOCKCOLON,
144
+ BLOCKCOMMA: BLOCKCOMMA,
145
+ COMMENT: COMMENT,
146
+ COMMENT_BLOCK: COMMENT_BLOCK,
147
+ FOR_EACH: FOR_EACH,
148
+ IMPORT: IMPORT,
149
+ INLINE: INLINE,
150
+ INLINECOLON: INLINECOLON,
151
+ INLINECOMMA: INLINECOMMA,
152
+ RUNTIME_LOGIC: RUNTIME_LOGIC,
153
+ SEMICOLON: SEMICOLON,
154
+ SLOT: SLOT,
155
+ STATIC_LOGIC: STATIC_LOGIC,
156
+ TEXT: TEXT,
157
+ USE_MODULE: USE_MODULE,
158
+ at_end: at_end,
159
+ at_id: at_id,
160
+ at_value: at_value,
161
+ atblock_key: atblock_key,
162
+ block_end: block_end,
163
+ block_id: block_id,
164
+ block_key: block_key,
165
+ block_value: block_value,
166
+ end_keyword: end_keyword,
167
+ for_each_keyword: for_each_keyword,
168
+ inline_id: inline_id,
169
+ inline_text: inline_text,
170
+ slot_keyword: slot_keyword
171
+ });
172
+
173
+ /**
174
+ * Wraps your text in a color if colors are turned on.
175
+ *
176
+ * @param {string} color - The color to use (red, green, yellow, blue, magenta, or cyan).
177
+ * @param {string} text - The text you want to color.
178
+ * @returns {string} - The colored text, or plain text if colors are off.
179
+ * @throws {Error} - Fails if you forget to provide the text.
180
+ */
181
+ function colorize(color, text) {
182
+ if (!text) throw new Error("argument 'text' is not defined.");
183
+ return text;
184
+ }
185
+
186
+ /**
187
+ * SomMark Errors
188
+ * Handles formatting and throwing errors with beautiful CLI coloring and pointers.
189
+ */
190
+
191
+ // ========================================================================== //
192
+ // Message Formatting //
193
+ // ========================================================================== //
194
+
195
+ /**
196
+ * Processes a message by applying colors and formatting.
197
+ * Supports:
198
+ * - {line} : Adds a horizontal line
199
+ * - {N} : Adds a new line
200
+ * - <$color: Text$> : Adds color (red, yellow, green, blue, magenta, cyan)
201
+ *
202
+ * @param {string|string[]} text - The message or list of message parts to format.
203
+ * @returns {string} - The final formatted and colored string.
204
+ */
205
+ function formatMessage(text) {
206
+ const horizontal_rule = "\n----------------------------------------------------------------------------------------------\n";
207
+ const pattern = /<\$([^:]+):([\s\S]*?)\$>/g;
208
+
209
+ if (Array.isArray(text)) {
210
+ text = text.join("");
211
+ }
212
+
213
+ text = text.replace(pattern, (match, color, content) => {
214
+ return colorize(color, content.trim());
215
+ });
216
+ text = text.replaceAll("{line}", horizontal_rule);
217
+ text = text.replaceAll("{N}", "\n");
218
+
219
+ text = text
220
+ .split("\n")
221
+ .filter(value => value !== "")
222
+ .join("\n")
223
+ .trim();
224
+
225
+ return text;
226
+ }
227
+
228
+ /**
229
+ * Creates a detailed error message showing where the error happened in the code.
230
+ * It adds a line number, a snippet of the code, and a pointer (^) to the exact spot.
231
+ *
232
+ * @param {string} src - The original code being parsed.
233
+ * @param {Object} range - The location of the error (line and character).
234
+ * @param {string|null} filename - The name of the file (optional).
235
+ * @param {string|string[]} message - The error message to show.
236
+ * @param {string} typeName - The type of error (e.g., "Lexer" or "Parser").
237
+ * @returns {string[]} - A list of message parts that make up the final error report.
238
+ */
239
+ function formatErrorWithContext(src, range, filename, message, typeName) {
240
+ if (!src || !range || !range.start) return message;
241
+
242
+ const lines = src.split("\n");
243
+ const lineIndex = range.start.line;
244
+ const lineContent = lines[lineIndex] || "";
245
+ const pointerPadding = " ".repeat(range.start.character);
246
+ const sourceLabel = filename ? ` [${filename}]` : "";
247
+
248
+ const rangeInfo =
249
+ range.start.line === range.end.line
250
+ ? `from column <$yellow:${range.start.character}$> to <$yellow:${range.end.character}$>`
251
+ : `from line <$yellow:${range.start.line + 1}$>, column <$yellow:${range.start.character}$> to line <$yellow:${range.end.line + 1}$>, column <$yellow:${range.end.character}$>`;
252
+
253
+ const formattedMessage = [
254
+ `<$blue:{line}$><$red:Here where error occurred${sourceLabel}:$>{N}${lineContent}{N}${pointerPadding}<$yellow:^$>{N}{N}`,
255
+ `<$red:${typeName} Error:$> `,
256
+ ...(Array.isArray(message) ? message : [message]),
257
+ `{N}at line <$yellow:${range.start.line + 1}$>, ${rangeInfo}{N}`,
258
+ "<$blue:{line}$>"
259
+ ];
260
+
261
+ return formattedMessage;
262
+ }
263
+
264
+ // ========================================================================== //
265
+ // Error Classes //
266
+ // ========================================================================== //
267
+
268
+ /** Base class for all SomMark errors that automatically formats messages for the terminal. */
269
+ class CustomError extends Error {
270
+ /**
271
+ * Creates a new error.
272
+ *
273
+ * @param {string|string[]} message - The text describing what went wrong.
274
+ * @param {string} name - The name of the error type.
275
+ */
276
+ constructor(message, name) {
277
+ super(message);
278
+ this.name = name;
279
+ this.message = formatMessage(`<$cyan:[${this.name}]$>:`) + "\n" + formatMessage(message);
280
+ if (Error.captureStackTrace) {
281
+ Error.captureStackTrace(this, this.constructor);
282
+ }
283
+ }
284
+ }
285
+
286
+ class ParserError extends CustomError {
287
+ constructor(message) { super(message, "Parser Error"); }
288
+ }
289
+
290
+ class LexerError extends CustomError {
291
+ constructor(message) { super(message, "Lexer Error"); }
292
+ }
293
+
294
+ class TranspilerError extends CustomError {
295
+ constructor(message) { super(message, "Transpiler Error"); }
296
+ }
297
+
298
+ class CLIError extends CustomError {
299
+ constructor(message) { super(message, "CLI Error"); }
300
+ }
301
+
302
+ class RuntimeError extends CustomError {
303
+ constructor(message) { super(message, "Runtime Error"); }
304
+ }
305
+
306
+ class SommarkError extends CustomError {
307
+ constructor(message) { super(message, "SomMark Error"); }
308
+ }
309
+
310
+ // ========================================================================== //
311
+ // Error Dispatcher (Helper) //
312
+ // ========================================================================== //
313
+
314
+ /**
315
+ * A helper that creates an error "dispatcher" for a specific category.
316
+ *
317
+ * @param {string} type - The category of error (e.g., 'lexer', 'parser').
318
+ * @returns {Function} - A function that throws the formatted error.
319
+ */
320
+ function getError(type) {
321
+ const validate_msg = msg => (Array.isArray(msg) && msg.length > 0) || typeof msg === "string";
322
+ const typeNames = {
323
+ parser: "Parser",
324
+ transpiler: "Transpiler",
325
+ lexer: "Lexer",
326
+ cli: "CLI",
327
+ runtime: "Runtime",
328
+ sommark: "SomMark"
329
+ };
330
+ const ErrorClasses = {
331
+ parser: ParserError,
332
+ transpiler: TranspilerError,
333
+ lexer: LexerError,
334
+ cli: CLIError,
335
+ runtime: RuntimeError,
336
+ sommark: SommarkError
337
+ };
338
+
339
+ return (errorMessage, context = null) => {
340
+ if (validate_msg(errorMessage)) {
341
+ let finalMessage = errorMessage;
342
+ if (context && context.src && context.range) {
343
+ finalMessage = formatErrorWithContext(
344
+ context.src,
345
+ context.range,
346
+ context.filename,
347
+ errorMessage,
348
+ typeNames[type]
349
+ );
350
+ }
351
+ throw new ErrorClasses[type](finalMessage).message;
352
+ }
353
+ };
354
+ }
355
+
356
+ /** Helper to throw Lexer errors. */
357
+ const lexerError = getError("lexer");
358
+
359
+ /** Helper to throw Parser errors. */
360
+ const parserError = getError("parser");
361
+
362
+ /** Helper to throw Runtime or Module errors. */
363
+ const runtimeError = getError("runtime");
364
+
365
+ /** Helper to throw general internal SomMark errors. */
366
+ const sommarkError = getError("sommark");
367
+
368
+ /**
369
+ * SomMark Lexer
370
+ *
371
+ * Transforms a raw SomMark source string into a stream of tokens.
372
+ * It uses a state-machine approach to handle complex contexts like At-Block bodies,
373
+ * quoted values, and hierarchical headers.
374
+ *
375
+ * @param {string} src - The raw SomMark source code.
376
+ * @param {string} [filename="anonymous"] - Source filename for error reporting.
377
+ * @returns {Array<Object>} Array of token objects.
378
+ */
379
+ function lexer(src, filename = "anonymous") {
380
+ if (!src || typeof src !== "string") return [];
381
+ const tokens = [];
382
+ let last_non_junk_type = ""; // Tracks the last real token for context guessing
383
+ let i = 0;
384
+ let line = 0, character = 0;
385
+
386
+ // State Variables
387
+ let isInAtBlockBody = false;
388
+ let isInQuote = false;
389
+ let isInHeader = false; // Tracks if we are in a structural header context
390
+ let isInAtBlockHeader = false; // Specific for At-Block headers (@_ ... _@)
391
+ let isInInlineHead = false; // Specific for (key:val) after ->
392
+ let parenDepth = 0; // To track balanced parentheses in inlines
393
+
394
+ /**
395
+ * Adds a token to the stream and updates the scanner's position tracking.
396
+ *
397
+ * @param {string} type - The type of token (from TOKEN_TYPES).
398
+ * @param {string} value - The literal text content of the token.
399
+ */
400
+ function addToken(type, value) {
401
+ const start = { line, character };
402
+
403
+ // Update position
404
+ const parts = value.split("\n");
405
+ if (parts.length > 1) {
406
+ line += parts.length - 1;
407
+ character = parts[parts.length - 1].length;
408
+ } else {
409
+ character += value.length;
410
+ }
411
+
412
+ const end = { line, character };
413
+ tokens.push({
414
+ type,
415
+ value,
416
+ source: filename,
417
+ range: { start, end }
418
+ });
419
+ if (type !== TOKEN_TYPES.WHITESPACE && type !== TOKEN_TYPES.COMMENT) {
420
+ if (type !== TOKEN_TYPES.TEXT || value.trim() !== "") {
421
+ last_non_junk_type = type;
422
+ }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Looks ahead to find the next structural character, skipping whitespace and comments.
428
+ * Used for context-guessing (e.g., distinguishing KEY from VALUE).
429
+ *
430
+ * @param {number} start - Index to start peeking from.
431
+ * @returns {string|null} The next structural character or null if EOF.
432
+ */
433
+ function peekStructural(start) {
434
+ let j = start;
435
+ while (j < src.length) {
436
+ const c = src[j];
437
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") {
438
+ j++;
439
+ continue;
440
+ }
441
+ if (c === "#") {
442
+ while (j < src.length && src[j] !== "\n") j++;
443
+ continue;
444
+ }
445
+ if (c === "\\") {
446
+ // Escape sequence: jump over the backslash and the escaped char
447
+ j += 2;
448
+ continue;
449
+ }
450
+ return c;
451
+ }
452
+ return null;
453
+ }
454
+
455
+ while (i < src.length) {
456
+ // --- PHASE 1: AT-BLOCK BODY MODE ---
457
+ // In this mode, we consume everything as raw text until we hit the @_ marker.
458
+ if (isInAtBlockBody) {
459
+ if (src[i] === "@" && src[i + 1] === "_") {
460
+ isInAtBlockBody = false;
461
+ } else {
462
+ let body = "";
463
+ while (i < src.length) {
464
+ // Handle escapes in At-Block Body
465
+ if (src[i] === "\\" && i + 1 < src.length) {
466
+ body += src[i + 1];
467
+ i += 2;
468
+ continue;
469
+ }
470
+ // Stop at end marker
471
+ if (src[i] === "@" && src[i + 1] === "_") {
472
+ break;
473
+ }
474
+ body += src[i];
475
+ i++;
476
+ }
477
+ if (body.length > 0) {
478
+ addToken(TOKEN_TYPES.TEXT, body);
479
+ }
480
+ continue;
481
+ }
482
+ }
483
+ const char = src[i];
484
+ const next = src[i + 1];
485
+
486
+ // --- PHASE 2: QUOTE MODE ---
487
+ // Handles balanced strings and allows prefix layers (js{}, p{}) inside them.
488
+ if (isInQuote) {
489
+ let quoteValue = "";
490
+ const quoteChar = tokens[tokens.length - 1].value;
491
+ while (i < src.length) {
492
+ if (src[i] === "\\" && i + 1 < src.length) {
493
+ // Inside quotes, we split escapes if we want to match reliability tests
494
+ if (quoteValue.length > 0) addToken(TOKEN_TYPES.VALUE, quoteValue);
495
+ addToken(TOKEN_TYPES.ESCAPE, "\\" + src[i + 1]);
496
+ quoteValue = "";
497
+ i += 2;
498
+ continue;
499
+ }
500
+
501
+ // Support Prefix Layers inside quotes!
502
+ if ((src[i] === "j" && src[i + 1] === "s" && src[i + 2] === "{") || (src[i] === "p" && src[i + 1] === "{") || (src[i] === "v" && src[i + 1] === "{")) {
503
+ const isJS = (src[i] === "j");
504
+ const isV = (src[i] === "v");
505
+ if (quoteValue.length > 0) {
506
+ addToken(TOKEN_TYPES.VALUE, quoteValue);
507
+ quoteValue = "";
508
+ }
509
+
510
+ let braceDepth = 1;
511
+ let prefixValue = isJS ? "js{" : (isV ? "v{" : "p{");
512
+ i += isJS ? 3 : 2;
513
+
514
+ let internalString = null;
515
+ while (i < src.length && braceDepth > 0) {
516
+ const c = src[i];
517
+ const n = src[i + 1];
518
+ if (internalString) {
519
+ if (c === "\\" && (n === internalString || n === "\\")) {
520
+ prefixValue += c + n;
521
+ i += 2;
522
+ continue;
523
+ }
524
+ if (c === internalString) internalString = null;
525
+ } else {
526
+ if (c === "\"" || c === "'") internalString = c;
527
+ else if (c === "{") braceDepth++;
528
+ else if (c === "}") braceDepth--;
529
+ }
530
+ prefixValue += c;
531
+ i++;
532
+ }
533
+ let tokenType = isJS ? TOKEN_TYPES.PREFIX_JS : (isV ? TOKEN_TYPES.PREFIX_V : TOKEN_TYPES.PREFIX_P);
534
+ addToken(tokenType, prefixValue);
535
+ continue;
536
+ }
537
+
538
+ if (src[i] === quoteChar) {
539
+ // Guess role based on next structural character
540
+ let nextStructural = peekStructural(i + 1);
541
+ let tokenType = (isInHeader || isInInlineHead) && (nextStructural === ":" || nextStructural === "=")
542
+ ? TOKEN_TYPES.KEY
543
+ : TOKEN_TYPES.VALUE;
544
+
545
+ if (quoteValue.length > 0) addToken(tokenType, quoteValue);
546
+ addToken(TOKEN_TYPES.QUOTE, quoteChar);
547
+ isInQuote = false;
548
+ i++;
549
+ break;
550
+ }
551
+ quoteValue += src[i];
552
+ i++;
553
+ }
554
+ if (!isInQuote) continue;
555
+ }
556
+
557
+ // --- PHASE 3: STRUCTURAL PARSING ---
558
+ // Handles markers, whitespace, and structural symbols.
559
+
560
+ // WHITESPACE
561
+ if (char === "\n") {
562
+ addToken(TOKEN_TYPES.WHITESPACE, char);
563
+ i++;
564
+ continue;
565
+ }
566
+
567
+ if (char === " " || char === "\t" || char === "\r") {
568
+ let ws = "";
569
+ while (i < src.length && (src[i] === " " || src[i] === "\t" || src[i] === "\r")) {
570
+ ws += src[i];
571
+ i++;
572
+ }
573
+ addToken(TOKEN_TYPES.WHITESPACE, ws);
574
+ continue;
575
+ }
576
+
577
+ // COMMENTS
578
+ if (char === "#") {
579
+ let comm = "";
580
+ // Check for Multiline Comment ### (must have no spaces)
581
+ if (src[i + 1] === "#" && src[i + 2] === "#") {
582
+ comm = "###";
583
+ i += 3;
584
+ while (i < src.length) {
585
+ if (src[i] === "#" && src[i + 1] === "#" && src[i + 2] === "#") {
586
+ comm += "###";
587
+ i += 3;
588
+ break;
589
+ }
590
+ comm += src[i];
591
+ i++;
592
+ }
593
+ addToken(TOKEN_TYPES.COMMENT_BLOCK, comm);
594
+ } else {
595
+ // Single line comment
596
+ while (i < src.length && src[i] !== "\n") {
597
+ comm += src[i];
598
+ i++;
599
+ }
600
+ addToken(TOKEN_TYPES.COMMENT, comm);
601
+ }
602
+ continue;
603
+ }
604
+
605
+ // ESCAPE CHARACTER (Sequence-based)
606
+ if (char === "\\") {
607
+ const seq = i + 1 < src.length ? "\\" + src[i + 1] : "\\";
608
+ addToken(TOKEN_TYPES.ESCAPE, seq);
609
+ i += seq.length;
610
+ continue;
611
+ }
612
+
613
+ // PREFIX LAYERS (js{...} or p{...} or v{...})
614
+ if ((char === "j" && next === "s" && src[i + 2] === "{") || (char === "p" && next === "{") || (char === "v" && next === "{")) {
615
+ const isJS = (char === "j");
616
+ const isP = (char === "p");
617
+ const isV = (char === "v");
618
+
619
+ // Context Check
620
+ const isBlockHeader = isInHeader && !isInAtBlockHeader;
621
+ const isNormalText = !isInHeader && !isInInlineHead && !isInAtBlockBody && parenDepth === 0;
622
+
623
+ let allowed = false;
624
+ if (isJS && isBlockHeader) allowed = true;
625
+ if (isP && (isBlockHeader || isNormalText)) allowed = true;
626
+ if (isV && (isBlockHeader || isNormalText)) allowed = true;
627
+
628
+ if (allowed) {
629
+ let braceDepth = 1;
630
+ let prefixValue = isJS ? "js{" : (isV ? "v{" : "p{");
631
+ i += isJS ? 3 : 2;
632
+
633
+ let inString = null; // Track if we are inside " " or ' '
634
+ while (i < src.length && braceDepth > 0) {
635
+ const c = src[i];
636
+ const n = src[i + 1];
637
+
638
+ if (inString) {
639
+ if (c === "\\" && (n === inString || n === "\\")) {
640
+ prefixValue += c + n;
641
+ i += 2;
642
+ continue;
643
+ }
644
+ if (c === inString) inString = null;
645
+ } else {
646
+ if (c === "\"" || c === "'") inString = c;
647
+ else if (c === "{") braceDepth++;
648
+ else if (c === "}") braceDepth--;
649
+ }
650
+ prefixValue += c;
651
+ i++;
652
+ }
653
+ let tokenType = isJS ? TOKEN_TYPES.PREFIX_JS : (isV ? TOKEN_TYPES.PREFIX_V : TOKEN_TYPES.PREFIX_P);
654
+ addToken(tokenType, prefixValue);
655
+ continue;
656
+ }
657
+ // If not allowed, it will fall through to normal word scanning
658
+ }
659
+
660
+ // MULTI-CHAR MARKERS
661
+ if (char === "@" && next === "_") {
662
+ addToken(TOKEN_TYPES.OPEN_AT, "@_");
663
+ i += 2;
664
+ isInHeader = true; // At-Blocks start with a header part
665
+ isInAtBlockHeader = true;
666
+ continue;
667
+ }
668
+ if (char === "-" && next === ">") {
669
+ if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
670
+ addToken(TOKEN_TYPES.TEXT, "-");
671
+ i++; // Swallowed one char
672
+ } else {
673
+ addToken(TOKEN_TYPES.THIN_ARROW, "->");
674
+ i += 2;
675
+ isInInlineHead = true; // The following ( ) will be structural
676
+ }
677
+ continue;
678
+ }
679
+
680
+ // STATIC KEYWORD
681
+ if (char === "s" && src.slice(i, i + 6) === "static") {
682
+ const afterStatic = src.slice(i + 6);
683
+ const hasSpace = afterStatic.startsWith(" ");
684
+ const hasLogic = hasSpace ? afterStatic.slice(1).startsWith("${") : afterStatic.startsWith("${");
685
+
686
+ const isMainIdentifier = (
687
+ last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET ||
688
+ last_non_junk_type === TOKEN_TYPES.OPEN_AT ||
689
+ (last_non_junk_type === TOKEN_TYPES.OPEN_PAREN && isInInlineHead)
690
+ );
691
+
692
+ if ((hasLogic || isInHeader) && !isMainIdentifier) {
693
+ addToken(TOKEN_TYPES.STATIC_KEYWORD, hasSpace ? "static " : "static");
694
+ i += hasSpace ? 7 : 6;
695
+ continue;
696
+ }
697
+ }
698
+
699
+ // RUNTIME KEYWORD
700
+ if (char === "r" && src.slice(i, i + 7) === "runtime") {
701
+ const afterRuntime = src.slice(i + 7);
702
+ const hasSpace = afterRuntime.startsWith(" ");
703
+ const hasLogic = hasSpace ? afterRuntime.slice(1).startsWith("${") : afterRuntime.startsWith("${");
704
+
705
+ const isMainIdentifier = (
706
+ last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET ||
707
+ last_non_junk_type === TOKEN_TYPES.OPEN_AT ||
708
+ (last_non_junk_type === TOKEN_TYPES.OPEN_PAREN && isInInlineHead)
709
+ );
710
+
711
+ if ((hasLogic || isInHeader) && !isMainIdentifier) {
712
+ addToken(TOKEN_TYPES.RUNTIME_KEYWORD, hasSpace ? "runtime " : "runtime");
713
+ i += hasSpace ? 8 : 7;
714
+ continue;
715
+ }
716
+ }
717
+
718
+ // LOGIC BLOCKS (${ ... }$)
719
+ if (char === "$" && next === "{" && (last_non_junk_type === TOKEN_TYPES.STATIC_KEYWORD || last_non_junk_type === TOKEN_TYPES.RUNTIME_KEYWORD)) {
720
+ const startLine = line;
721
+ const startCharacter = character;
722
+ i += 2;
723
+ let logicCode = "";
724
+ let braceDepth = 1;
725
+ let internalString = null;
726
+ let foundClosing = false;
727
+
728
+ while (i < src.length) {
729
+ const c = src[i];
730
+ const n = src[i + 1];
731
+
732
+ // Stop condition: }$ (only if not inside a JS string and at top-level brace depth)
733
+ if (c === "}" && n === "$" && !internalString && braceDepth === 1) {
734
+ i += 2;
735
+ braceDepth = 0;
736
+ foundClosing = true;
737
+ break;
738
+ }
739
+
740
+ if (internalString) {
741
+ if (c === "\\" && (n === internalString || n === "\\")) {
742
+ logicCode += c + n;
743
+ i += 2;
744
+ continue;
745
+ }
746
+ if (c === internalString) internalString = null;
747
+ } else {
748
+ if (c === "/" && n === "/") {
749
+ logicCode += c + n;
750
+ i += 2;
751
+ while (i < src.length && src[i] !== "\n" && src[i] !== "\r") {
752
+ logicCode += src[i];
753
+ i++;
754
+ }
755
+ continue;
756
+ }
757
+ if (c === "/" && n === "*") {
758
+ logicCode += c + n;
759
+ i += 2;
760
+ while (i < src.length) {
761
+ if (src[i] === "*" && src[i + 1] === "/") {
762
+ logicCode += "*/";
763
+ i += 2;
764
+ break;
765
+ }
766
+ logicCode += src[i];
767
+ i++;
768
+ }
769
+ continue;
770
+ }
771
+
772
+ if (c === "\"" || c === "'" || c === "`") internalString = c;
773
+ else if (c === "{") braceDepth++;
774
+ else if (c === "}") braceDepth--;
775
+ }
776
+
777
+ logicCode += c;
778
+ i++;
779
+ }
780
+
781
+ if (!foundClosing) {
782
+ lexerError("Unclosed logic block. Expected '}$' to close the block starting with '${'.", {
783
+ src,
784
+ filename,
785
+ range: {
786
+ start: { line: startLine, character: startCharacter },
787
+ end: { line: startLine, character: startCharacter + 2 }
788
+ }
789
+ });
790
+ }
791
+
792
+ addToken(TOKEN_TYPES.LOGIC, logicCode);
793
+ continue;
794
+ }
795
+
796
+ // SINGLE-CHAR MARKERS
797
+ if (char === "[") {
798
+ if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
799
+ addToken(TOKEN_TYPES.TEXT, "[");
800
+ } else {
801
+ addToken(TOKEN_TYPES.OPEN_BRACKET, "[");
802
+ isInHeader = true;
803
+ }
804
+ i++;
805
+ continue;
806
+ }
807
+ if (char === "_" && next === "@") {
808
+ if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
809
+ addToken(TOKEN_TYPES.TEXT, "_@");
810
+ } else {
811
+ const lastRealType = last_non_junk_type;
812
+ addToken(TOKEN_TYPES.CLOSE_AT, "_@");
813
+ // Removed delimiter stack check
814
+ if (lastRealType === TOKEN_TYPES.END_KEYWORD) {
815
+ isInAtBlockBody = false;
816
+ isInHeader = false;
817
+ isInAtBlockHeader = false;
818
+ }
819
+ }
820
+ i += 2;
821
+ continue;
822
+ }
823
+ if (char === "]") {
824
+ if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
825
+ addToken(TOKEN_TYPES.TEXT, "]");
826
+ } else {
827
+ addToken(TOKEN_TYPES.CLOSE_BRACKET, "]");
828
+ isInHeader = false;
829
+ }
830
+ i++;
831
+ continue;
832
+ }
833
+ if (char === "(") {
834
+ if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
835
+ addToken(TOKEN_TYPES.TEXT, "(");
836
+ parenDepth++;
837
+ } else {
838
+ addToken(TOKEN_TYPES.OPEN_PAREN, "(");
839
+ parenDepth++;
840
+ }
841
+ i++;
842
+ continue;
843
+ }
844
+ if (char === ")") {
845
+ if (isInAtBlockBody || (parenDepth > 1 && !isInInlineHead)) {
846
+ addToken(TOKEN_TYPES.TEXT, ")");
847
+ parenDepth--;
848
+ } else if (parenDepth > 0) {
849
+ // This ends the content part if depth drops to 0
850
+ parenDepth--;
851
+ if (parenDepth === 0) {
852
+ addToken(TOKEN_TYPES.CLOSE_PAREN, ")");
853
+ if (isInInlineHead) {
854
+ isInInlineHead = false;
855
+ isInHeader = false;
856
+ }
857
+ } else {
858
+ addToken(TOKEN_TYPES.TEXT, ")");
859
+ }
860
+ } else {
861
+ addToken(TOKEN_TYPES.TEXT, ")");
862
+ }
863
+ i++;
864
+ continue;
865
+ }
866
+ if (char === ":") {
867
+ if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
868
+ addToken(TOKEN_TYPES.TEXT, ":");
869
+ } else {
870
+ const allowed = [TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.KEY, TOKEN_TYPES.CLOSE_AT, TOKEN_TYPES.VALUE, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.QUOTE, TOKEN_TYPES.PREFIX_JS, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
871
+ if (allowed.includes(last_non_junk_type)) {
872
+ addToken(TOKEN_TYPES.COLON, ":");
873
+ isInHeader = true;
874
+ } else {
875
+ addToken(TOKEN_TYPES.TEXT, ":");
876
+ }
877
+ }
878
+ i++;
879
+ continue;
880
+ }
881
+ if (char === "=") {
882
+ if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
883
+ addToken(TOKEN_TYPES.TEXT, "=");
884
+ } else {
885
+ const allowed = [TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.KEY, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.QUOTE, TOKEN_TYPES.PREFIX_JS, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
886
+ if (allowed.includes(last_non_junk_type)) {
887
+ addToken(TOKEN_TYPES.EQUAL, "=");
888
+ } else {
889
+ addToken(TOKEN_TYPES.TEXT, "=");
890
+ }
891
+ }
892
+ i++;
893
+ continue;
894
+ }
895
+ if (char === ",") {
896
+ if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
897
+ addToken(TOKEN_TYPES.TEXT, ",");
898
+ } else {
899
+ const allowed = [TOKEN_TYPES.VALUE, TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.QUOTE, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.PREFIX_JS, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
900
+ if (allowed.includes(last_non_junk_type)) {
901
+ addToken(TOKEN_TYPES.COMMA, ",");
902
+ } else {
903
+ addToken(TOKEN_TYPES.TEXT, ",");
904
+ }
905
+ }
906
+ i++;
907
+ continue;
908
+ }
909
+ if (char === ";") {
910
+ if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
911
+ addToken(TOKEN_TYPES.TEXT, ";");
912
+ } else {
913
+ const allowed = [TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.VALUE, TOKEN_TYPES.CLOSE_AT, TOKEN_TYPES.CLOSE_PAREN, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.QUOTE, TOKEN_TYPES.PREFIX_JS, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
914
+ if (allowed.includes(last_non_junk_type)) {
915
+ addToken(TOKEN_TYPES.SEMICOLON, ";");
916
+ // ONLY trigger body mode if we were actually in an At-Block header
917
+ if (isInAtBlockHeader) {
918
+ isInHeader = false;
919
+ isInAtBlockHeader = false;
920
+ isInAtBlockBody = true;
921
+ }
922
+ } else {
923
+ addToken(TOKEN_TYPES.TEXT, ";");
924
+ }
925
+ }
926
+ i++;
927
+ continue;
928
+ }
929
+ if (char === "!") {
930
+ if (isInHeader) {
931
+ addToken(TOKEN_TYPES.EXCLAMATION_MARK, "!");
932
+ i++;
933
+ continue;
934
+ }
935
+ }
936
+ if (char === "\"" || char === "'") {
937
+ const valTriggers = [TOKEN_TYPES.COLON, TOKEN_TYPES.EQUAL, TOKEN_TYPES.COMMA, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.OPEN_BRACKET, TOKEN_TYPES.OPEN_AT];
938
+ const wasValueTrigger = valTriggers.includes(last_non_junk_type);
939
+ addToken(TOKEN_TYPES.QUOTE, char);
940
+ i++;
941
+ // Enable quote mode
942
+ // NOTE: We allow quotes basically anywhere in headers as values/keys
943
+ if (isInHeader || wasValueTrigger) {
944
+ isInQuote = true;
945
+ }
946
+ continue;
947
+ }
948
+
949
+ // --- PHASE 4: WORD / TEXT SCANNING ---
950
+ // This is the "Fallback" mode where we scan for identifiers, keys, or values.
951
+ // It uses lookahead and context variables to guess the role of a word.
952
+ let word = "";
953
+ // Only Blocks ([ ]) allow ':' in their main identifier.
954
+ // At-Blocks (@_) and Inlines (->( )) do NOT allow ':' in the ID.
955
+ const isStartOfBlockId = (last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET);
956
+
957
+ let stopChars = "[](){}:=;,@>\"'#\\ \t\n\r!";
958
+ if (isStartOfBlockId || (parenDepth > 0 && !isInInlineHead)) {
959
+ stopChars = stopChars.replace(":", "");
960
+ }
961
+ const isInNormalText = !isInHeader && !isInInlineHead && !isInAtBlockBody;
962
+ if (isInNormalText) {
963
+ stopChars = "[]@()>_()\\#\n\r"; // In normal text, stop at markers, comments and newlines
964
+ }
965
+
966
+ while (i < src.length && !stopChars.includes(src[i])) {
967
+ // Stop ONLY if $ is followed by { (Logic block start)
968
+ if (src[i] === "$" && src[i + 1] === "{") break;
969
+
970
+ // Lookahead for At-Block markers (_@ or @_)
971
+ if (src[i] === "_" && src[i + 1] === "@") break;
972
+ if (src[i] === "@" && src[i + 1] === "_") break;
973
+
974
+ // Lookahead for 'static ${' or 'runtime ${' (only if we're not at the very start of the word scanning)
975
+ if (word.length > 0) {
976
+ if (src[i] === "s" && src.slice(i, i + 7) === "static " && src[i + 7] === "$" && src[i + 8] === "{") break;
977
+ if (src[i] === "s" && src.slice(i, i + 6) === "static" && src[i + 6] === "$" && src[i + 7] === "{") break;
978
+ if (src[i] === "r" && src.slice(i, i + 8) === "runtime " && src[i + 8] === "$" && src[i + 9] === "{") break;
979
+ if (src[i] === "r" && src.slice(i, i + 7) === "runtime" && src[i + 7] === "$" && src[i + 8] === "{") break;
980
+ }
981
+
982
+ // Lookahead for -> marker in normal text
983
+ if (!isInHeader && src[i] === "-" && src[i + 1] === ">") break;
984
+
985
+ // Stop if we hit an ALLOWED prefix trigger
986
+ if ((src[i] === "p" && src[i + 1] === "{") || (src[i] === "v" && src[i + 1] === "{")) {
987
+ if (isInHeader || isInNormalText) break;
988
+ }
989
+ if (src[i] === "j" && src[i + 1] === "s" && src[i + 2] === "{") {
990
+ if (isInHeader) break;
991
+ }
992
+ word += src[i];
993
+ i++;
994
+ }
995
+
996
+ if (word.length > 0) {
997
+ // Guess role based on context
998
+ if (parenDepth > 0 && !isInInlineHead) {
999
+ // Inside Inline Content (raw text)
1000
+ addToken(TOKEN_TYPES.TEXT, word);
1001
+ } else if (isInHeader || isInInlineHead) {
1002
+ // Inside a structural header context
1003
+ const isMainIdentifier = (
1004
+ last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET ||
1005
+ last_non_junk_type === TOKEN_TYPES.OPEN_AT ||
1006
+ (last_non_junk_type === TOKEN_TYPES.OPEN_PAREN && isInInlineHead)
1007
+ );
1008
+
1009
+ if (isMainIdentifier) {
1010
+ if (word === end_keyword) {
1011
+ addToken(TOKEN_TYPES.END_KEYWORD, word);
1012
+ }
1013
+ else if (word === "import") addToken(TOKEN_TYPES.IMPORT, word);
1014
+ else if (word === "$use-module") addToken(TOKEN_TYPES.USE_MODULE, word);
1015
+ else if (word === "slot") addToken(TOKEN_TYPES.SLOT_KEYWORD, word);
1016
+ else if (word === "for-each") addToken(TOKEN_TYPES.FOR_EACH, word);
1017
+ else addToken(TOKEN_TYPES.IDENTIFIER, word);
1018
+ } else {
1019
+ // Use lookahead to distinguish KEY from VALUE
1020
+ const p = peekStructural(i);
1021
+ if (p === ":") {
1022
+ addToken(TOKEN_TYPES.KEY, word);
1023
+ } else if (word === "static") {
1024
+ addToken(TOKEN_TYPES.STATIC_KEYWORD, word);
1025
+ } else if (word === "runtime") {
1026
+ addToken(TOKEN_TYPES.RUNTIME_KEYWORD, word);
1027
+ } else {
1028
+ addToken(TOKEN_TYPES.VALUE, word);
1029
+ }
1030
+ }
1031
+ } else {
1032
+ // Normal text
1033
+ if (word.trim() === "static") {
1034
+ addToken(TOKEN_TYPES.STATIC_KEYWORD, word);
1035
+ } else if (word.trim() === "runtime") {
1036
+ addToken(TOKEN_TYPES.RUNTIME_KEYWORD, word);
1037
+ } else {
1038
+ addToken(TOKEN_TYPES.TEXT, word);
1039
+ }
1040
+ }
1041
+ } else {
1042
+ // Fallback for any unhandled characters
1043
+ if (i < src.length) {
1044
+ addToken(TOKEN_TYPES.TEXT, src[i]);
1045
+ i++;
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ addToken(TOKEN_TYPES.EOF, "");
1051
+ return tokens;
1052
+ }
1053
+
1054
+ /**
1055
+ * A safe parser that turns Javascript-like strings into real objects and arrays.
1056
+ * It is built to handle data structures without running any dangerous code or
1057
+ * accessing other parts of your project.
1058
+ *
1059
+ * It supports:
1060
+ * - Standard JSON: {"key": "val"}
1061
+ * - Javascript-style: { key: 'val' }
1062
+ * - Basic data: true, false, null, numbers, and strings
1063
+ */
1064
+ function safeDataParse(str) {
1065
+ if (typeof str !== "string") return str;
1066
+ const s = str.trim();
1067
+ if (!s) return null;
1068
+
1069
+ let index = 0;
1070
+
1071
+ function skipWhitespace() {
1072
+ while (index < s.length && /\s/.test(s[index])) {
1073
+ index++;
1074
+ }
1075
+ }
1076
+
1077
+ function parseValue() {
1078
+ skipWhitespace();
1079
+ const char = s[index];
1080
+
1081
+ if (char === '{') return parseObject();
1082
+ if (char === '[') return parseArray();
1083
+ if (char === '"' || char === "'") return parseString();
1084
+
1085
+ // Primitives or Unquoted identifiers
1086
+ return parsePrimitiveOrIdentifier();
1087
+ }
1088
+
1089
+ function parseString() {
1090
+ const quote = s[index++];
1091
+ let result = "";
1092
+ while (index < s.length && s[index] !== quote) {
1093
+ if (s[index] === '\\') index++; // Skip escape
1094
+ result += s[index++];
1095
+ }
1096
+ index++; // Skip closing quote
1097
+ return result;
1098
+ }
1099
+
1100
+ function parseObject() {
1101
+ index++; // Skip {
1102
+ const obj = {};
1103
+ skipWhitespace();
1104
+
1105
+ while (index < s.length && s[index] !== '}') {
1106
+ skipWhitespace();
1107
+ // Key can be unquoted, quoted "key", or quoted 'key'
1108
+ let key;
1109
+ if (s[index] === '"' || s[index] === "'") {
1110
+ key = parseString();
1111
+ } else {
1112
+ let keyMatch = s.slice(index).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/);
1113
+ if (!keyMatch) break;
1114
+ key = keyMatch[0];
1115
+ index += key.length;
1116
+ }
1117
+
1118
+ skipWhitespace();
1119
+ if (s[index] !== ':') break;
1120
+ index++; // Skip :
1121
+
1122
+ obj[key] = parseValue();
1123
+
1124
+ skipWhitespace();
1125
+ if (s[index] === ',') index++; // Skip optional comma
1126
+ skipWhitespace();
1127
+ }
1128
+ index++; // Skip }
1129
+ return obj;
1130
+ }
1131
+
1132
+ function parseArray() {
1133
+ index++; // Skip [
1134
+ const arr = [];
1135
+ skipWhitespace();
1136
+
1137
+ while (index < s.length && s[index] !== ']') {
1138
+ arr.push(parseValue());
1139
+ skipWhitespace();
1140
+ if (s[index] === ',') index++; // Skip optional comma
1141
+ skipWhitespace();
1142
+ }
1143
+ index++; // Skip ]
1144
+ return arr;
1145
+ }
1146
+
1147
+ function parsePrimitiveOrIdentifier() {
1148
+ const start = index;
1149
+ while (index < s.length && /[a-zA-Z0-9_$+\-.]/.test(s[index])) {
1150
+ index++;
1151
+ }
1152
+ const token = s.slice(start, index);
1153
+
1154
+ if (token === "true") return true;
1155
+ if (token === "false") return false;
1156
+ if (token === "null") return null;
1157
+ if (!isNaN(Number(token))) return Number(token);
1158
+
1159
+ return token; // Fallback to string if it looks like an identifier
1160
+ }
1161
+
1162
+ try {
1163
+ return parseValue();
1164
+ } catch (e) {
1165
+ return str; // Fallback to raw string if parsing fails
1166
+ }
1167
+ }
1168
+
1169
+ /**
1170
+ * Calculates the Levenshtein distance between two strings.
1171
+ * Used for "Did you mean?" suggestions and fuzzy matching in validation.
1172
+ *
1173
+ * @param {string} a - First string.
1174
+ * @param {string} b - Second string.
1175
+ * @returns {number} - The edit distance between the two strings.
1176
+ */
1177
+ function levenshtein(a, b) {
1178
+ const matrix = [];
1179
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
1180
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
1181
+
1182
+ for (let i = 1; i <= b.length; i++) {
1183
+ for (let j = 1; j <= a.length; j++) {
1184
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
1185
+ matrix[i][j] = matrix[i - 1][j - 1];
1186
+ } else {
1187
+ matrix[i][j] = Math.min(
1188
+ matrix[i - 1][j - 1] + 1,
1189
+ matrix[i][j - 1] + 1,
1190
+ matrix[i - 1][j] + 1
1191
+ );
1192
+ }
1193
+ }
1194
+ }
1195
+ return matrix[b.length][a.length];
1196
+ }
1197
+
1198
+ // -- Unresolved Placeholder Helpers ---------------------------------------- //
1199
+
1200
+ const UNRESOLVED_PREFIX = "SOMMARK_UNRESOLVED";
1201
+ const UNRESOLVED_SUFFIX = "SOMMARK";
1202
+
1203
+ /**
1204
+ * Official method to get the unique envelope for an unresolved prefix value.
1205
+ * @param {string} prefix - The layer ('p' or 'v').
1206
+ * @param {string} expectedValue - The placeholder key.
1207
+ * @returns {string} - The unique envelope string.
1208
+ */
1209
+ function getPrefixValue(prefix, expectedValue) {
1210
+ if (!prefix || (prefix !== "p" && prefix !== "v")) {
1211
+ sommarkError([
1212
+ `<$red:getPrefixValue Error:$> {N}`,
1213
+ `<$yellow:prefix must be 'p' or 'v'. Received:$> <$cyan:'${prefix}'$>`
1214
+ ]);
1215
+ }
1216
+
1217
+ if (!expectedValue || typeof expectedValue !== "string" || expectedValue.trim() === "") {
1218
+ sommarkError([
1219
+ `<$red:getPrefixValue Error:$> {N}`,
1220
+ `<$yellow:expectedValue must be a non-empty string. Received:$> <$cyan:'${expectedValue}'$>`
1221
+ ]);
1222
+ }
1223
+
1224
+ return `${UNRESOLVED_PREFIX}_${prefix}_${expectedValue}_${UNRESOLVED_SUFFIX}`;
1225
+ }
1226
+
1227
+ /**
1228
+ * SomMark Parser
1229
+ */
1230
+
1231
+
1232
+ // ========================================================================== //
1233
+ // Helper Functions //
1234
+ // ========================================================================== //
1235
+
1236
+ /**
1237
+ * Returns the token at the current position.
1238
+ *
1239
+ * @param {Object[]} tokens - The list of tokens.
1240
+ * @param {number} i - The current index.
1241
+ * @returns {Object|null} - The token or null if at the end.
1242
+ */
1243
+ function current_token(tokens, i) {
1244
+ return tokens[i] || null;
1245
+ }
1246
+
1247
+ /**
1248
+ * Skip whitespaces and comments in structural contexts.
1249
+ *
1250
+ * @param {Object[]} tokens - The list of tokens.
1251
+ * @param {number} i - The current index.
1252
+ * @returns {number} - The new index.
1253
+ */
1254
+ function skipJunk(tokens, i) {
1255
+ while (i < tokens.length) {
1256
+ const t = tokens[i];
1257
+ const type = t.type;
1258
+ if (type === TOKEN_TYPES.WHITESPACE || type === TOKEN_TYPES.COMMENT || type === TOKEN_TYPES.COMMENT_BLOCK) {
1259
+ i++;
1260
+ } else if (type === TOKEN_TYPES.TEXT && t.value.trim() === "") {
1261
+ i++;
1262
+ } else {
1263
+ break;
1264
+ }
1265
+ }
1266
+ return i;
1267
+ }
1268
+
1269
+ /**
1270
+ * Checks if a name is valid (using letters, numbers, and certain symbols).
1271
+ *
1272
+ * @param {string} id - The name to check.
1273
+ * @param {RegExp} [keyRegex] - The rule to follow.
1274
+ * @param {string} [name] - The type of thing we are checking.
1275
+ * @param {string} [rule] - A human-readable version of the rule.
1276
+ * @param {string} [ruleMessage] - The error message to show.
1277
+ */
1278
+ function validateName(
1279
+ id,
1280
+ allowColon = false,
1281
+ name = "Identifier"
1282
+ ) {
1283
+ const keyRegex = allowColon ? /^[a-zA-Z0-9\-_$:]+$/ : /^[a-zA-Z0-9\-_$]+$/;
1284
+ const rule = allowColon ? "(A–Z, a–z, 0–9, -, _, $, :)" : "(A–Z, a–z, 0–9, -, _, $)";
1285
+ const ruleMessage = allowColon
1286
+ ? "must contain only letters, numbers, hyphens, underscores, dollar signs ($), or colons (:)"
1287
+ : "must contain only letters, numbers, hyphens, underscores, or dollar signs ($)";
1288
+
1289
+ if (!keyRegex.test(id)) {
1290
+ parserError([`{line}<$red:Invalid ${name}:$><$blue: '${id}'$>{N}<$yellow:${name} ${ruleMessage}$> <$cyan: ${rule}.$>{line}`]);
1291
+ }
1292
+ }
1293
+
1294
+ /** Creates a new empty Block node. */
1295
+ function makeBlockNode() {
1296
+ return {
1297
+ type: BLOCK,
1298
+ structure: "Block",
1299
+ id: "",
1300
+ args: {},
1301
+ body: [],
1302
+ depth: 0,
1303
+ range: {
1304
+ start: { line: 0, character: 0 },
1305
+ end: { line: 0, character: 0 }
1306
+ }
1307
+ };
1308
+ }
1309
+ /** Creates a new empty Text node. */
1310
+ function makeTextNode() {
1311
+ return {
1312
+ type: TEXT,
1313
+ structure: "Text",
1314
+ text: "",
1315
+ depth: 0,
1316
+ range: {
1317
+ start: { line: 0, character: 0 },
1318
+ end: { line: 0, character: 0 }
1319
+ }
1320
+ };
1321
+ }
1322
+ /** Creates a new empty Comment node. */
1323
+ function makeCommentNode() {
1324
+ return {
1325
+ type: COMMENT,
1326
+ structure: "Comment",
1327
+ text: "",
1328
+ depth: 0,
1329
+ range: {
1330
+ start: { line: 0, character: 0 },
1331
+ end: { line: 0, character: 0 }
1332
+ }
1333
+ };
1334
+ }
1335
+ /** Creates a new empty Inline node. */
1336
+ function makeInlineNode() {
1337
+ return {
1338
+ type: INLINE,
1339
+ structure: "Inline",
1340
+ value: "",
1341
+ id: "",
1342
+ args: {},
1343
+ depth: 0,
1344
+ range: {
1345
+ start: { line: 0, character: 0 },
1346
+ end: { line: 0, character: 0 }
1347
+ }
1348
+ };
1349
+ }
1350
+
1351
+ // ========================================================================== //
1352
+ // Node Creators //
1353
+ // ========================================================================== //
1354
+ /** Creates a new empty AtBlock node. */
1355
+ function makeAtBlockNode() {
1356
+ return {
1357
+ type: ATBLOCK,
1358
+ structure: "AtBlock",
1359
+ id: "",
1360
+ args: {},
1361
+ content: "",
1362
+ depth: 0,
1363
+ range: {
1364
+ start: { line: 0, character: 0 },
1365
+ end: { line: 0, character: 0 }
1366
+ }
1367
+ };
1368
+ }
1369
+
1370
+ /** Creates a new empty Logic node. */
1371
+ function makeLogicNode(type = RUNTIME_LOGIC) {
1372
+ return {
1373
+ type: type,
1374
+ structure: "Block",
1375
+ code: "",
1376
+ depth: 0,
1377
+ range: {
1378
+ start: { line: 0, character: 0 },
1379
+ end: { line: 0, character: 0 }
1380
+ }
1381
+ };
1382
+ }
1383
+ let end_stack = [];
1384
+ let tokens_stack = [];
1385
+
1386
+ const fallback = {
1387
+ value: "Unknown",
1388
+ range: {
1389
+ start: { line: 0, character: 0 },
1390
+ end: { line: 0, character: 0 }
1391
+ }};
1392
+ const updateData = (tokens, i) => {
1393
+ if (tokens[i]) {
1394
+ tokens_stack.push(tokens[i].value);
1395
+ tokens[i].range;
1396
+ tokens[i].value;
1397
+ }
1398
+ };
1399
+
1400
+ const errorMessage = (tokens, i, expectedValue, behindValue, frontText, filename = null) => {
1401
+ const current = tokens[i] || fallback;
1402
+ const errorLineNumber = current.range.start.line;
1403
+ current.range.start.character;
1404
+ const source = current.source || filename;
1405
+ const sourceLabel = source ? ` [${source}]` : "";
1406
+
1407
+ let lineStartIndex = i;
1408
+ while (
1409
+ lineStartIndex > 0 &&
1410
+ tokens[lineStartIndex - 1] &&
1411
+ tokens[lineStartIndex - 1].range.start.line === errorLineNumber &&
1412
+ (tokens[lineStartIndex - 1].source || filename) === source
1413
+ ) {
1414
+ lineStartIndex--;
1415
+ }
1416
+
1417
+ let lineEndIndex = i;
1418
+ while (
1419
+ lineEndIndex < tokens.length - 1 &&
1420
+ tokens[lineEndIndex + 1] &&
1421
+ tokens[lineEndIndex + 1].range.start.line === errorLineNumber &&
1422
+ (tokens[lineEndIndex + 1].source || filename) === source
1423
+ ) {
1424
+ lineEndIndex++;
1425
+ }
1426
+
1427
+ // Get all tokens on the error line
1428
+ const lineTokens = tokens.slice(lineStartIndex, lineEndIndex + 1);
1429
+ const lineContent = lineTokens.map(t => t.value).join('');
1430
+
1431
+ // Get content on the line before the error token
1432
+ const tokensBeforeErrorOnLine = tokens.slice(lineStartIndex, i);
1433
+ const contentBeforeErrorOnLine = tokensBeforeErrorOnLine.map(t => t.value).join('');
1434
+
1435
+ const pointerPadding = " ".repeat(contentBeforeErrorOnLine.length);
1436
+ const rangeInfo = current.range.start.line === current.range.end.line
1437
+ ? `from column <$yellow:${current.range.start.character}$> to <$yellow:${current.range.end.character}$>`
1438
+ : `from line <$yellow:${current.range.start.line + 1}$>, column <$yellow:${current.range.start.character}$> to line <$yellow:${current.range.end.line + 1}$>, column <$yellow:${current.range.end.character}$>`;
1439
+
1440
+ return [
1441
+ `<$blue:{line}$><$red:Here where error occurred${sourceLabel}:$>{N}${lineContent}{N}${pointerPadding}<$yellow:^$>{N}{N}`,
1442
+ `<$red:${frontText ? frontText : "Expected token"}$>${!frontText ? " <$blue:'" + expectedValue + "'$>" : ""} ${behindValue ? "after <$blue:'" + behindValue + "'$>" : ""} at line <$yellow:${current.range.start.line + 1}$>,`,
1443
+ ` ${rangeInfo}`,
1444
+ `{N}<$yellow:Received:$> <$blue:'${current.value === "\n" ? "\\n' (newline)" : current.value}'$>`,
1445
+ ` at line <$yellow:${current.range.start.line + 1}$>,`,
1446
+ ` ${rangeInfo}{N}`,
1447
+ "<$blue:{line}$>"
1448
+ ];
1449
+ };
1450
+ // ========================================================================== //
1451
+ // Parse Key //
1452
+ // ========================================================================== //
1453
+ function parseKey(tokens, i) {
1454
+ let key = "";
1455
+ if (current_token(tokens, i).type === TOKEN_TYPES.QUOTE) {
1456
+ i++; // consume opening QUOTE
1457
+ key = current_token(tokens, i).value;
1458
+ i++; // consume Key
1459
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.QUOTE) {
1460
+ i++; // consume closing QUOTE
1461
+ }
1462
+ } else {
1463
+ key = current_token(tokens, i).value.trim();
1464
+ i++;
1465
+ }
1466
+ updateData(tokens, i);
1467
+ return [key, i];
1468
+ }
1469
+ // ========================================================================== //
1470
+ // Parse Value //
1471
+ // ========================================================================== //
1472
+ function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = true) {
1473
+ let val = current_token(tokens, i).value;
1474
+ // consume Value
1475
+ if (current_token(tokens, i).type === TOKEN_TYPES.QUOTE) {
1476
+ i++; // consume opening QUOTE
1477
+ val = "";
1478
+ while (i < tokens.length && current_token(tokens, i).type !== TOKEN_TYPES.QUOTE) {
1479
+ const token = current_token(tokens, i);
1480
+ if (token.type === TOKEN_TYPES.PREFIX_P || token.type === TOKEN_TYPES.PREFIX_JS || token.type === TOKEN_TYPES.PREFIX_V) {
1481
+ const [resolvedVal, nextI] = parseValue(tokens, i, placeholders, variables, allowLogic);
1482
+ val += resolvedVal;
1483
+ i = nextI;
1484
+ } else {
1485
+ val += token.value;
1486
+ i++;
1487
+ }
1488
+ }
1489
+
1490
+ if (i >= tokens.length) {
1491
+ parserError(errorMessage(tokens, i - 1, "\"", "unclosed string", "Unclosed quote"));
1492
+ }
1493
+
1494
+ i++; // consume closing QUOTE
1495
+ return [val, i, true];
1496
+ } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_JS) {
1497
+ val = current_token(tokens, i).value;
1498
+ // V4 NATIVE DATA: Strip js{ } and parse safely
1499
+ if (val.startsWith("js{") && val.endsWith("}")) {
1500
+ const clean = val.slice(3, -1).trim();
1501
+ val = safeDataParse(clean);
1502
+ }
1503
+ i++;
1504
+ return [val, i, false];
1505
+ } else if (current_token(tokens, i).type === TOKEN_TYPES.LOGIC || current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD) {
1506
+ if (!allowLogic) {
1507
+ parserError(errorMessage(tokens, i, "literal value", "", "Logic blocks are not allowed in this context."));
1508
+ }
1509
+ let isStatic = current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD;
1510
+ let isRuntimeKeyword = current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD;
1511
+ let nextI = i;
1512
+
1513
+ if (isStatic || isRuntimeKeyword) {
1514
+ nextI = skipJunk(tokens, i + 1);
1515
+ if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC) {
1516
+ // Treat as literal text if keyword is not followed by a logic block
1517
+ return [current_token(tokens, i).value, i + 1, false];
1518
+ }
1519
+ i = nextI;
1520
+ }
1521
+
1522
+ const logicToken = current_token(tokens, i);
1523
+ const node = makeLogicNode(isStatic ? STATIC_LOGIC : RUNTIME_LOGIC);
1524
+ node.code = logicToken.value;
1525
+ node.range = logicToken.range;
1526
+
1527
+ return [node, i + 1, false];
1528
+ } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_V) {
1529
+ val = current_token(tokens, i).value;
1530
+ // V4.1.0 VARIABLE: Strip v{ } and resolve from local variables
1531
+ if (val.startsWith("v{") && val.endsWith("}")) {
1532
+ const key = val.slice(2, -1).trim();
1533
+ if (variables[key] !== undefined) {
1534
+ val = variables[key];
1535
+ if (!variables.__consumed__) {
1536
+ Object.defineProperty(variables, "__consumed__", {
1537
+ value: new Set(),
1538
+ enumerable: false,
1539
+ configurable: true
1540
+ });
1541
+ }
1542
+ variables.__consumed__.add(key);
1543
+ } else {
1544
+ val = getPrefixValue('v', key);
1545
+ }
1546
+ }
1547
+ i++;
1548
+ return [val, i, false];
1549
+ } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_C) {
1550
+ val = current_token(tokens, i).value;
1551
+ // PREFIX_C is preserved for the resolveModules expansion phase
1552
+ i++;
1553
+ return [val, i, false];
1554
+ } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_P) {
1555
+ val = current_token(tokens, i).value;
1556
+ // V4 PLACEHOLDER: Strip p{ } and resolve from config
1557
+ if (val.startsWith("p{") && val.endsWith("}")) {
1558
+ const key = val.slice(2, -1).trim();
1559
+ val = placeholders[key] !== undefined ? placeholders[key] : getPrefixValue('p', key);
1560
+ }
1561
+ i++;
1562
+ return [val, i, false];
1563
+ } else {
1564
+ val = "";
1565
+ while (i < tokens.length) {
1566
+ const token = current_token(tokens, i);
1567
+ if (!token) break;
1568
+
1569
+ // Stop at any structural marker or whitespace
1570
+ if (token.type === TOKEN_TYPES.WHITESPACE ||
1571
+ token.type === TOKEN_TYPES.COMMA ||
1572
+ token.type === TOKEN_TYPES.CLOSE_BRACKET ||
1573
+ token.type === TOKEN_TYPES.COLON ||
1574
+ token.type === TOKEN_TYPES.SEMICOLON ||
1575
+ token.type === TOKEN_TYPES.EXCLAMATION_MARK ||
1576
+ token.type === TOKEN_TYPES.CLOSE_PAREN) break;
1577
+
1578
+ if (token.type === TOKEN_TYPES.ESCAPE) {
1579
+ // Remove backslash
1580
+ val += token.value.slice(1);
1581
+ } else {
1582
+ val += token.value;
1583
+ }
1584
+ i++;
1585
+ }
1586
+ }
1587
+
1588
+ updateData(tokens, i);
1589
+ return [val, i, false];
1590
+ }
1591
+ // ========================================================================== //
1592
+ // Parse ',' //
1593
+ // ========================================================================== //
1594
+ function parseComma(tokens, i, beforeChar = "") {
1595
+ i = skipJunk(tokens, i);
1596
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COMMA) {
1597
+ i++;
1598
+ i = skipJunk(tokens, i);
1599
+ updateData(tokens, i);
1600
+
1601
+ if (
1602
+ !current_token(tokens, i) ||
1603
+ (current_token(tokens, i) &&
1604
+ current_token(tokens, i).type !== TOKEN_TYPES.VALUE &&
1605
+ current_token(tokens, i).type !== TOKEN_TYPES.ESCAPE &&
1606
+ current_token(tokens, i).type !== TOKEN_TYPES.IDENTIFIER &&
1607
+ current_token(tokens, i).type !== TOKEN_TYPES.KEY &&
1608
+ current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
1609
+ current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_JS &&
1610
+ current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_P)
1611
+ ) {
1612
+ parserError(errorMessage(tokens, i, "value", ","));
1613
+ }
1614
+ } else {
1615
+ parserError(errorMessage(tokens, i, ",", beforeChar));
1616
+ }
1617
+ return i;
1618
+ }
1619
+ // ========================================================================== //
1620
+ // Parse ':' //
1621
+ // ========================================================================== //
1622
+ function parseColon(tokens, i, afterChar = "") {
1623
+ i = skipJunk(tokens, i);
1624
+ if (!current_token(tokens, i) || (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.COLON)) {
1625
+ parserError(errorMessage(tokens, i, ":", afterChar));
1626
+ }
1627
+ i++;
1628
+ i = skipJunk(tokens, i);
1629
+ updateData(tokens, i);
1630
+ return i;
1631
+ }
1632
+ // ========================================================================== //
1633
+ // Parse ';' //
1634
+ // ========================================================================== //
1635
+ function parseSemiColon(tokens, i, afterChar = "") {
1636
+ i = skipJunk(tokens, i);
1637
+ if (!current_token(tokens, i) || (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.SEMICOLON)) {
1638
+ parserError(errorMessage(tokens, i, ";", afterChar));
1639
+ }
1640
+ i++;
1641
+ i = skipJunk(tokens, i);
1642
+ updateData(tokens, i);
1643
+ return i;
1644
+ }
1645
+ /**
1646
+ * Parses a standard SomMark Block ([id] ... [end]).
1647
+ * Blocks are structural elements that can contain nested content.
1648
+ *
1649
+ * @param {Object[]} tokens - Token stream.
1650
+ * @param {number} i - Initial index.
1651
+ * @param {string|null} filename - Source filename.
1652
+ * @param {Object} placeholders - Dynamic public API data.
1653
+ * @returns {[Object, number]} The parsed Block node and new index.
1654
+ */
1655
+ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {}, depth = 0) {
1656
+ const blockNode = makeBlockNode();
1657
+ blockNode.depth = depth;
1658
+ const openBracketToken = current_token(tokens, i);
1659
+ // ========================================================================== //
1660
+ // consume '[' //
1661
+ // ========================================================================== //
1662
+ i++;
1663
+ i = skipJunk(tokens, i);
1664
+ updateData(tokens, i);
1665
+
1666
+ const idToken = current_token(tokens, i);
1667
+ if (!idToken || idToken.type === TOKEN_TYPES.EOF) {
1668
+ parserError(errorMessage(tokens, i, "Block ID", "[", "Missing Block Identifier"));
1669
+ }
1670
+ const id = idToken.value;
1671
+ if (id.trim() === end_keyword) {
1672
+ parserError(errorMessage(tokens, i, id, "", `'${id.trim()}' is a reserved keyword and cannot be used as an identifier.`));
1673
+ }
1674
+ blockNode.id = id.trim();
1675
+ if (!blockNode.id) {
1676
+ parserError(errorMessage(tokens, i, "Block ID", "[", "Block identifier cannot be empty"));
1677
+ }
1678
+ if (blockNode.id === "import") {
1679
+ blockNode.type = IMPORT;
1680
+ } else if (blockNode.id === "$use-module") {
1681
+ blockNode.type = USE_MODULE;
1682
+ } else if (idToken.type === TOKEN_TYPES.SLOT_KEYWORD) {
1683
+ blockNode.type = SLOT;
1684
+ // Prevent nested slots
1685
+ if (end_stack.some(e => e.id === "slot")) {
1686
+ parserError(errorMessage(tokens, i, "slot", "", "Nested slots are not allowed. A [slot] cannot be placed inside another [slot]."));
1687
+ }
1688
+ } else if (idToken.type === TOKEN_TYPES.FOR_EACH || blockNode.id === "for-each") {
1689
+ blockNode.type = FOR_EACH;
1690
+ }
1691
+ validateName(blockNode.id, true);
1692
+ blockNode.range.start = openBracketToken.range.start;
1693
+ end_stack.push({ id, line: openBracketToken.range.start.line + 1, col: openBracketToken.range.start.character });
1694
+ // ========================================================================== //
1695
+ // consume Block Identifier //
1696
+ // ========================================================================== //
1697
+ i++;
1698
+ i = skipJunk(tokens, i);
1699
+ updateData(tokens, i);
1700
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.EQUAL) {
1701
+ // ========================================================================== //
1702
+ // consume '=' //
1703
+ // ========================================================================== //
1704
+ i++;
1705
+ i = skipJunk(tokens, i);
1706
+ updateData(tokens, i);
1707
+
1708
+ if (
1709
+ !current_token(tokens, i) ||
1710
+ (current_token(tokens, i) &&
1711
+ current_token(tokens, i).type !== TOKEN_TYPES.VALUE &&
1712
+ current_token(tokens, i).type !== TOKEN_TYPES.ESCAPE &&
1713
+ current_token(tokens, i).type !== TOKEN_TYPES.IDENTIFIER &&
1714
+ current_token(tokens, i).type !== TOKEN_TYPES.IMPORT &&
1715
+ current_token(tokens, i).type !== TOKEN_TYPES.USE_MODULE &&
1716
+ current_token(tokens, i).type !== TOKEN_TYPES.END_KEYWORD &&
1717
+ current_token(tokens, i).type !== TOKEN_TYPES.KEY &&
1718
+ current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
1719
+ current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_JS &&
1720
+ current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_V &&
1721
+ current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_P &&
1722
+ current_token(tokens, i).type !== TOKEN_TYPES.LOGIC &&
1723
+ current_token(tokens, i).type !== TOKEN_TYPES.STATIC_KEYWORD &&
1724
+ current_token(tokens, i).type !== TOKEN_TYPES.RUNTIME_KEYWORD)
1725
+ ) {
1726
+ parserError(errorMessage(tokens, i, block_value, "="));
1727
+ }
1728
+ // ========================================================================== //
1729
+ // consume key-Value //
1730
+ // ========================================================================== //
1731
+ let k = "";
1732
+ let v = "";
1733
+ let vIsQuoted = false;
1734
+ let argIndex = 0;
1735
+ while (i < tokens.length) {
1736
+ i = skipJunk(tokens, i);
1737
+ const token = current_token(tokens, i);
1738
+ if (!token || token.type === TOKEN_TYPES.CLOSE_BRACKET) break;
1739
+
1740
+ const isQuotedKey = token.type === TOKEN_TYPES.QUOTE && peek(tokens, i, 1) && (peek(tokens, i, 1).type === TOKEN_TYPES.KEY);
1741
+
1742
+ if (token.type === TOKEN_TYPES.KEY || isQuotedKey) {
1743
+ let [key, keyIndex] = parseKey(tokens, i);
1744
+ k = key;
1745
+ i = keyIndex;
1746
+ i = skipJunk(tokens, i);
1747
+ i = parseColon(tokens, i, block_key);
1748
+ i = skipJunk(tokens, i);
1749
+
1750
+ // Ensure there is a value after the colon
1751
+ const nextToken = current_token(tokens, i);
1752
+ if (!nextToken || nextToken.type === TOKEN_TYPES.CLOSE_BRACKET || nextToken.type === TOKEN_TYPES.COMMA) {
1753
+ parserError(errorMessage(tokens, i, block_value, ":", "Missing value after colon"));
1754
+ }
1755
+
1756
+ // Validate only if it was a plain KEY token (not from a quote)
1757
+ if (token.type === TOKEN_TYPES.KEY) {
1758
+ validateName(k, true);
1759
+ }
1760
+ }
1761
+
1762
+ // Parse Value (handles both quoted, unquoted, and prefixes)
1763
+ let [value, valueIndex, isQuoted] = parseValue(tokens, i, placeholders, variables);
1764
+ v = value;
1765
+ vIsQuoted = isQuoted;
1766
+ i = valueIndex;
1767
+
1768
+ // Store Argument
1769
+ blockNode.args[String(argIndex++)] = v;
1770
+ if (k) {
1771
+ blockNode.args[k] = v;
1772
+ }
1773
+ k = "";
1774
+ v = "";
1775
+
1776
+ i = skipJunk(tokens, i);
1777
+ const separatorToken = current_token(tokens, i);
1778
+ if (separatorToken && (separatorToken.type === TOKEN_TYPES.COMMA || separatorToken.type === TOKEN_TYPES.COLON)) {
1779
+ i++; // consume , or :
1780
+ i = skipJunk(tokens, i);
1781
+ updateData(tokens, i);
1782
+
1783
+ // Ensure next token is NOT the closing bracket (trailing separator)
1784
+ const afterSeparator = current_token(tokens, i);
1785
+ if (!afterSeparator || afterSeparator.type === TOKEN_TYPES.CLOSE_BRACKET) {
1786
+ parserError(errorMessage(tokens, i, "value", "", "Unexpected trailing separator"));
1787
+ }
1788
+ } else {
1789
+ // No separator, must be end of arguments or ]
1790
+ break;
1791
+ }
1792
+ }
1793
+ if (v !== "") {
1794
+ if (typeof v === "string") {
1795
+ if (!vIsQuoted) v = v.trim();
1796
+ if (v.startsWith('"') && v.endsWith('"')) {
1797
+ v = v.slice(1, -1);
1798
+ }
1799
+ }
1800
+ }
1801
+ }
1802
+
1803
+ i = skipJunk(tokens, i);
1804
+
1805
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.EXCLAMATION_MARK) {
1806
+ blockNode.isSelfClosing = true;
1807
+ i++;
1808
+ i = skipJunk(tokens, i);
1809
+ }
1810
+
1811
+ // ========================================================================== //
1812
+ // Close Bracket //
1813
+ // ========================================================================== //
1814
+ if (!current_token(tokens, i) || (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_BRACKET)) {
1815
+ parserError(errorMessage(tokens, i, "]", block_id));
1816
+ }
1817
+ // ========================================================================== //
1818
+ // consume ']' //
1819
+ // ========================================================================== //
1820
+ i++;
1821
+ updateData(tokens, i);
1822
+
1823
+ if (blockNode.isSelfClosing) {
1824
+ end_stack.pop();
1825
+ blockNode.range.end = current_token(tokens, i - 1).range.end;
1826
+ return [blockNode, i];
1827
+ }
1828
+
1829
+ tokens_stack.length = 0;
1830
+ while (i < tokens.length) {
1831
+ const nextIdx = skipJunk(tokens, i + 1);
1832
+ const nextToken = tokens[nextIdx];
1833
+ if (
1834
+ current_token(tokens, i) &&
1835
+ current_token(tokens, i).type === TOKEN_TYPES.OPEN_BRACKET &&
1836
+ nextToken &&
1837
+ nextToken.type !== TOKEN_TYPES.END_KEYWORD &&
1838
+ nextToken.value.trim() !== end_keyword
1839
+ ) {
1840
+ const [childNode, nextIndex] = parseBlock(tokens, i, filename, placeholders, variables, depth + 1);
1841
+
1842
+ blockNode.body.push(childNode);
1843
+ i = nextIndex;
1844
+ } else if (
1845
+ current_token(tokens, i) &&
1846
+ current_token(tokens, i).type === TOKEN_TYPES.OPEN_BRACKET &&
1847
+ nextToken &&
1848
+ (nextToken.type === TOKEN_TYPES.END_KEYWORD || nextToken.value.trim() === end_keyword)
1849
+ ) {
1850
+ // ========================================================================== //
1851
+ // consume '[' //
1852
+ // ========================================================================== //
1853
+ i++;
1854
+ i = skipJunk(tokens, i);
1855
+ const current = current_token(tokens, i);
1856
+ if (!current || (current.type !== TOKEN_TYPES.END_KEYWORD && current.value.trim() !== end_keyword)) {
1857
+ let extraInfo = "";
1858
+ if (current && current.value) {
1859
+ const dist = levenshtein(current.value.trim().toLowerCase(), "end");
1860
+ if (dist <= 2) {
1861
+ extraInfo = ` (Did you mean <$cyan:'[end]'$>?)`;
1862
+ }
1863
+ }
1864
+ parserError(errorMessage(tokens, i, "end", "[", extraInfo));
1865
+ }
1866
+ // ========================================================================== //
1867
+ // consume End Keyword //
1868
+ // ========================================================================== //
1869
+ i++;
1870
+ i = skipJunk(tokens, i);
1871
+ updateData(tokens, i);
1872
+ if (
1873
+ !current_token(tokens, i) ||
1874
+ (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_BRACKET)
1875
+ ) {
1876
+ parserError(errorMessage(tokens, i, "]", "end"));
1877
+ }
1878
+ end_stack.pop();
1879
+ // ========================================================================== //
1880
+ // consume ']' //
1881
+ // ========================================================================== //
1882
+ const closeBracketToken = current_token(tokens, i);
1883
+ i++;
1884
+ updateData(tokens, i);
1885
+ blockNode.range.end = closeBracketToken.range.end;
1886
+ break;
1887
+ } else if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.WHITESPACE) {
1888
+ blockNode.body.push({
1889
+ type: TEXT,
1890
+ text: current_token(tokens, i).value,
1891
+ range: current_token(tokens, i).range
1892
+ });
1893
+ i++;
1894
+ } else {
1895
+ const [childNode, nextIndex] = parseNode(tokens, i, filename, placeholders, variables, depth + 1);
1896
+ if (childNode) {
1897
+ blockNode.body.push(childNode);
1898
+ i = nextIndex;
1899
+ } else {
1900
+ i++; // Should not happen with current parseNode fallback but good for safety
1901
+ }
1902
+ }
1903
+ }
1904
+ return [blockNode, i];
1905
+ }
1906
+ /**
1907
+ * Parses an Inline Statement ((content) -> (id)).
1908
+ * Inlines are fast, non-nesting formatting elements.
1909
+ *
1910
+ * @param {Object[]} tokens - Token stream.
1911
+ * @param {number} i - Initial index.
1912
+ * @param {Object} placeholders - Dynamic public API data.
1913
+ * @returns {[Object, number]} The parsed Inline node and new index.
1914
+ */
1915
+ function parseInline(tokens, i, placeholders = {}, depth = 0) {
1916
+ const inlineNode = makeInlineNode();
1917
+ inlineNode.depth = depth;
1918
+ const openParenToken = current_token(tokens, i);
1919
+ inlineNode.range.start = openParenToken.range.start;
1920
+
1921
+ // consume '('
1922
+ i++;
1923
+ updateData(tokens, i);
1924
+
1925
+ // Phase 1: Content capture (Lexer provides high-level TEXT/ESCAPE tokens here)
1926
+ while (i < tokens.length) {
1927
+ const token = current_token(tokens, i);
1928
+ if (!token || token.type === TOKEN_TYPES.CLOSE_PAREN) break;
1929
+
1930
+ if (token.type === TOKEN_TYPES.ESCAPE) {
1931
+ inlineNode.value += token.value.slice(1);
1932
+ } else if (token.type !== TOKEN_TYPES.COMMENT) {
1933
+ inlineNode.value += token.value;
1934
+ }
1935
+ i++;
1936
+ }
1937
+
1938
+ if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_PAREN) {
1939
+ parserError(errorMessage(tokens, i, ")", "inline content"));
1940
+ }
1941
+ i++; // consume ')'
1942
+
1943
+ // Collapse newlines and whitespace for "inline" behavior
1944
+ inlineNode.value = inlineNode.value.replace(/\s+/g, " ").trim();
1945
+
1946
+ i = skipJunk(tokens, i);
1947
+ if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.THIN_ARROW) {
1948
+ parserError(errorMessage(tokens, i, "->", ")"));
1949
+ }
1950
+ i++; // consume '->'
1951
+
1952
+ i = skipJunk(tokens, i);
1953
+ if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.OPEN_PAREN) {
1954
+ parserError(errorMessage(tokens, i, "(", "->"));
1955
+ }
1956
+ i++; // consume '('
1957
+ i = skipJunk(tokens, i);
1958
+ const idToken = current_token(tokens, i);
1959
+ const allowedInlineIdTypes = new Set([
1960
+ TOKEN_TYPES.IDENTIFIER,
1961
+ TOKEN_TYPES.KEY,
1962
+ TOKEN_TYPES.IMPORT,
1963
+ TOKEN_TYPES.USE_MODULE,
1964
+ TOKEN_TYPES.SLOT_KEYWORD,
1965
+ TOKEN_TYPES.FOR_EACH
1966
+ ]);
1967
+ if (!idToken || !allowedInlineIdTypes.has(idToken.type)) {
1968
+ parserError(errorMessage(tokens, i, inline_id, "("));
1969
+ }
1970
+ inlineNode.id = idToken.value.trim();
1971
+ validateName(inlineNode.id);
1972
+
1973
+ i++; // consume ID
1974
+ i = skipJunk(tokens, i);
1975
+
1976
+ const hasArgsTrigger = current_token(tokens, i) && (
1977
+ current_token(tokens, i).type === TOKEN_TYPES.COLON ||
1978
+ current_token(tokens, i).type === TOKEN_TYPES.EQUAL
1979
+ );
1980
+
1981
+ if (hasArgsTrigger) {
1982
+ const separator = current_token(tokens, i).value;
1983
+ i++; // consume ':' or '='
1984
+ i = skipJunk(tokens, i);
1985
+
1986
+ // Ensure there is a value after the separator
1987
+ const nextToken = current_token(tokens, i);
1988
+ if (!nextToken || nextToken.type === TOKEN_TYPES.CLOSE_PAREN || nextToken.type === TOKEN_TYPES.COMMA) {
1989
+ parserError(errorMessage(tokens, i, inline_value, separator, `Missing value after ${separator === "=" ? "equals" : "colon"}`));
1990
+ }
1991
+
1992
+ let k = "";
1993
+ let v = "";
1994
+ let argIndex = 0;
1995
+
1996
+ while (i < tokens.length) {
1997
+ i = skipJunk(tokens, i);
1998
+ const token = current_token(tokens, i);
1999
+ if (!token || token.type === TOKEN_TYPES.CLOSE_PAREN) break;
2000
+
2001
+ if (token.type === TOKEN_TYPES.KEY) {
2002
+ let [key, keyIndex] = parseKey(tokens, i);
2003
+ k = key;
2004
+ i = keyIndex;
2005
+ i = skipJunk(tokens, i);
2006
+ i = parseColon(tokens, i, "inline argument");
2007
+ i = skipJunk(tokens, i);
2008
+
2009
+ // Ensure there is a value after the colon
2010
+ const nextToken = current_token(tokens, i);
2011
+ if (!nextToken || nextToken.type === TOKEN_TYPES.CLOSE_PAREN || nextToken.type === TOKEN_TYPES.COMMA) {
2012
+ parserError(errorMessage(tokens, i, inline_value, ":", "Missing value after colon"));
2013
+ }
2014
+ validateName(k);
2015
+ }
2016
+
2017
+ let [value, valueIndex, isQuoted] = parseValue(tokens, i, placeholders, {}, false);
2018
+ v = value;
2019
+ i = valueIndex;
2020
+
2021
+ inlineNode.args[String(argIndex++)] = v;
2022
+ if (k) {
2023
+ inlineNode.args[k] = v;
2024
+ }
2025
+ k = "";
2026
+ v = "";
2027
+
2028
+ i = skipJunk(tokens, i);
2029
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COMMA) {
2030
+ i = parseComma(tokens, i, "inline argument");
2031
+ } else {
2032
+ break;
2033
+ }
2034
+ }
2035
+ }
2036
+
2037
+ i = skipJunk(tokens, i);
2038
+ if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_PAREN) {
2039
+ parserError(errorMessage(tokens, i, ")", inlineNode.id));
2040
+ }
2041
+ const finalParenToken = current_token(tokens, i);
2042
+ i++; // consume ')'
2043
+ inlineNode.range.end = finalParenToken.range.end;
2044
+
2045
+ return [inlineNode, i];
2046
+ }
2047
+ /**
2048
+ * Parses a stream of text tokens into a single Text node.
2049
+ * Handles unescaping and placeholder resolution.
2050
+ *
2051
+ * @param {Object[]} tokens - Token stream.
2052
+ * @param {number} i - Initial index.
2053
+ * @param {Object} [placeholders={}] - Global data for p{keyword} resolution.
2054
+ * @param {Object} [variables={}] - Local data for v{keyword} resolution.
2055
+ * @param {Object} [options={}] - Formatting options.
2056
+ * @returns {[Object, number]} The Text node and new index.
2057
+ */
2058
+ function parseText(tokens, i, placeholders = {}, variables = {}, depth = 0, options = {}) {
2059
+ const textNode = makeTextNode();
2060
+ textNode.depth = depth;
2061
+ const startToken = current_token(tokens, i);
2062
+ textNode.range.start = startToken.range.start;
2063
+ const { selectiveUnescape = false } = options;
2064
+
2065
+ while (i < tokens.length) {
2066
+ const token = current_token(tokens, i);
2067
+ if (!token) break;
2068
+
2069
+ if (token.type === TOKEN_TYPES.TEXT || token.type === TOKEN_TYPES.WHITESPACE || token.type === TOKEN_TYPES.VALUE) {
2070
+ textNode.text += token.value;
2071
+ i++;
2072
+ } else if (token.type === TOKEN_TYPES.STATIC_KEYWORD || token.type === TOKEN_TYPES.RUNTIME_KEYWORD) {
2073
+ const nextIdx = skipJunk(tokens, i + 1);
2074
+ if (tokens[nextIdx] && tokens[nextIdx].type === TOKEN_TYPES.LOGIC) {
2075
+ // Stop consuming text; this is the start of a logic block
2076
+ break;
2077
+ }
2078
+ textNode.text += token.value;
2079
+ i++;
2080
+ } else if (token.type === TOKEN_TYPES.ESCAPE) {
2081
+ if (selectiveUnescape) {
2082
+ const char = token.value.slice(1);
2083
+ if (char === "@" || char === "_") {
2084
+ textNode.text += char;
2085
+ } else {
2086
+ textNode.text += token.value;
2087
+ }
2088
+ } else {
2089
+ textNode.text += token.value.slice(1); // Standard behavior: unescape all
2090
+ }
2091
+ i++;
2092
+ } else if (token.type === TOKEN_TYPES.PREFIX_P) {
2093
+ const val = token.value;
2094
+ if (val.startsWith("p{") && val.endsWith("}")) {
2095
+ const match = [val.slice(2, -1).trim(), val, 'p'];
2096
+ const key = match[0];
2097
+ const layer = match[2]; // 'p' or 'v'
2098
+
2099
+ if (placeholders[key] !== undefined) {
2100
+ textNode.text += String(placeholders[key]);
2101
+ } else {
2102
+ // Use the unique 'Unresolved Envelope' format via helper
2103
+ textNode.text += getPrefixValue(layer, key);
2104
+ }
2105
+ } else {
2106
+ textNode.text += val;
2107
+ }
2108
+ i++;
2109
+ } else if (token.type === TOKEN_TYPES.PREFIX_V) {
2110
+ const val = token.value;
2111
+ if (val.startsWith("v{") && val.endsWith("}")) {
2112
+ const key = val.slice(2, -1).trim();
2113
+ if (variables[key] !== undefined) {
2114
+ textNode.text += String(variables[key]);
2115
+ if (!variables.__consumed__) {
2116
+ Object.defineProperty(variables, "__consumed__", {
2117
+ value: new Set(),
2118
+ enumerable: false,
2119
+ configurable: true
2120
+ });
2121
+ }
2122
+ variables.__consumed__.add(key);
2123
+ } else {
2124
+ // Use the unique 'Unresolved Envelope' format via helper
2125
+ textNode.text += getPrefixValue('v', key);
2126
+ }
2127
+ } else {
2128
+ textNode.text += val;
2129
+ }
2130
+ i++;
2131
+ } else {
2132
+ break;
2133
+ }
2134
+
2135
+ updateData(tokens, i);
2136
+ textNode.range.end = tokens[i - 1].range.end;
2137
+ }
2138
+ return [textNode, i];
2139
+ }
2140
+ /**
2141
+ * Parses an At-Block (@_id_@: args; content @_end_@).
2142
+ * At-Blocks maintain raw content preservation.
2143
+ *
2144
+ * @param {Object[]} tokens - Token stream.
2145
+ * @param {number} i - Initial index.
2146
+ * @param {string|null} filename - Source filename.
2147
+ * @param {Object} placeholders - Dynamic public API data.
2148
+ * @returns {[Object, number]} The At-Block node and new index.
2149
+ */
2150
+ function parseAtBlock(tokens, i, filename = null, placeholders = {}, depth = 0) {
2151
+ const atBlockNode = makeAtBlockNode();
2152
+ atBlockNode.depth = depth;
2153
+ const openAtToken = current_token(tokens, i);
2154
+ atBlockNode.range.start = openAtToken.range.start;
2155
+
2156
+ // consume '@_'
2157
+ i++;
2158
+ i = skipJunk(tokens, i);
2159
+ updateData(tokens, i);
2160
+
2161
+ const idToken = current_token(tokens, i);
2162
+ if (!idToken || idToken.type === TOKEN_TYPES.EOF) {
2163
+ parserError(errorMessage(tokens, i, "AtBlock ID", "@_", "Missing AtBlock Identifier"));
2164
+ }
2165
+
2166
+ const id = idToken.value;
2167
+ if (id.trim() === end_keyword) {
2168
+ parserError(errorMessage(tokens, i, id, "", `'${id.trim()}' is a reserved keyword and cannot be used as an identifier.`));
2169
+ }
2170
+
2171
+ atBlockNode.id = id.trim();
2172
+ validateName(atBlockNode.id);
2173
+
2174
+ // consume ID
2175
+ i++;
2176
+ i = skipJunk(tokens, i);
2177
+ updateData(tokens, i);
2178
+
2179
+ if (!current_token(tokens, i) || (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_AT)) {
2180
+ parserError(errorMessage(tokens, i, "_@", "at-block identifier"));
2181
+ }
2182
+ // consume '_@'
2183
+ i++;
2184
+ i = skipJunk(tokens, i);
2185
+ updateData(tokens, i);
2186
+
2187
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COLON) {
2188
+ // consume ':'
2189
+ i++;
2190
+ i = skipJunk(tokens, i);
2191
+
2192
+ // Ensure there is a value after the colon
2193
+ const nextToken = current_token(tokens, i);
2194
+ if (!nextToken || nextToken.type === TOKEN_TYPES.SEMICOLON || nextToken.type === TOKEN_TYPES.COMMA) {
2195
+ parserError(errorMessage(tokens, i, at_value, ":", "Missing value after colon"));
2196
+ }
2197
+
2198
+ let k = "";
2199
+ let v = "";
2200
+ let argIndex = 0;
2201
+
2202
+ while (i < tokens.length) {
2203
+ i = skipJunk(tokens, i);
2204
+ const token = current_token(tokens, i);
2205
+ if (!token || token.type === TOKEN_TYPES.SEMICOLON) break;
2206
+
2207
+ const isQuotedKey = token.type === TOKEN_TYPES.QUOTE && peek(tokens, i, 1) && (peek(tokens, i, 1).type === TOKEN_TYPES.KEY);
2208
+
2209
+ if (token.type === TOKEN_TYPES.KEY || isQuotedKey) {
2210
+ let [key, keyIndex] = parseKey(tokens, i);
2211
+ k = key;
2212
+ i = keyIndex;
2213
+ i = skipJunk(tokens, i);
2214
+ i = parseColon(tokens, i, "at-block argument");
2215
+ i = skipJunk(tokens, i);
2216
+
2217
+ // Ensure there is a value after the colon
2218
+ const nextToken = current_token(tokens, i);
2219
+ if (!nextToken || nextToken.type === TOKEN_TYPES.SEMICOLON || nextToken.type === TOKEN_TYPES.COMMA) {
2220
+ parserError(errorMessage(tokens, i, at_value, ":", "Missing value after colon"));
2221
+ }
2222
+
2223
+ if (token.type === TOKEN_TYPES.KEY) {
2224
+ validateName(k);
2225
+ }
2226
+ }
2227
+
2228
+ let [value, valueIndex, isQuoted] = parseValue(tokens, i, placeholders, {}, false);
2229
+ v = value;
2230
+ i = valueIndex;
2231
+
2232
+ atBlockNode.args[String(argIndex++)] = v;
2233
+ if (k) {
2234
+ atBlockNode.args[k] = v;
2235
+ }
2236
+ k = "";
2237
+ v = "";
2238
+
2239
+ i = skipJunk(tokens, i);
2240
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COMMA) {
2241
+ i = parseComma(tokens, i, "at-block argument");
2242
+ } else {
2243
+ break;
2244
+ }
2245
+ }
2246
+ }
2247
+
2248
+ // Semicolon is ALWAYS required after ID or ARGS
2249
+ i = parseSemiColon(tokens, i, "at-block header");
2250
+
2251
+ // Body Capture
2252
+ i = skipJunk(tokens, i);
2253
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.TEXT) {
2254
+ atBlockNode.content = current_token(tokens, i).value;
2255
+ i++;
2256
+ } else {
2257
+ parserError(errorMessage(tokens, i, "content", "at-block body"));
2258
+ }
2259
+
2260
+ // End Marker (@_end_@)
2261
+ i = skipJunk(tokens, i);
2262
+ if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.OPEN_AT) {
2263
+ parserError(errorMessage(tokens, i, "@_", "at-block content"));
2264
+ }
2265
+ i++; // consume '@_'
2266
+ i = skipJunk(tokens, i);
2267
+ const endToken = current_token(tokens, i);
2268
+ if (!endToken || (endToken.type !== TOKEN_TYPES.END_KEYWORD && endToken.value.trim() !== end_keyword)) {
2269
+ let extraInfo = "";
2270
+ if (endToken && endToken.value) {
2271
+ const dist = levenshtein(endToken.value.trim().toLowerCase(), "end");
2272
+ if (dist > 0 && dist <= 2) {
2273
+ extraInfo = ` (Did you mean '@_end_@'?)`;
2274
+ }
2275
+ }
2276
+ parserError(errorMessage(tokens, i, "end", "AtBlock Body", extraInfo));
2277
+ }
2278
+ i++; // consume 'end'
2279
+ i = skipJunk(tokens, i);
2280
+ if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_AT) {
2281
+ parserError(errorMessage(tokens, i, "_@", "end marker"));
2282
+ }
2283
+ const closeAtToken = current_token(tokens, i);
2284
+ i++; // consume '_@'
2285
+ atBlockNode.range.end = closeAtToken.range.end;
2286
+
2287
+ return [atBlockNode, i];
2288
+ }
2289
+ // ========================================================================== //
2290
+ // Parse Comments //
2291
+ // ========================================================================== //
2292
+ function parseCommentNode(tokens, i, depth = 0) {
2293
+ const commentNode = makeCommentNode();
2294
+ const token = current_token(tokens, i);
2295
+ if (token && (token.type === TOKEN_TYPES.COMMENT || token.type === TOKEN_TYPES.COMMENT_BLOCK)) {
2296
+ commentNode.type = token.type === TOKEN_TYPES.COMMENT ? COMMENT : COMMENT_BLOCK;
2297
+ // Clean the text here instead of the transpiler
2298
+ const raw = token.value;
2299
+ commentNode.text = token.type === TOKEN_TYPES.COMMENT
2300
+ ? raw.replace(/^#/, "").trim()
2301
+ : raw.replace(/^###[\r\n]*/, "").replace(/[\r\n]*###$/, "").trim();
2302
+
2303
+ commentNode.depth = depth;
2304
+ commentNode.range = token.range;
2305
+ }
2306
+ // ========================================================================== //
2307
+ // consume Comment '#' //
2308
+ // ========================================================================== //
2309
+ i++;
2310
+ updateData(tokens, i);
2311
+ return [commentNode, i];
2312
+ }
2313
+
2314
+ // ========================================================================== //
2315
+ // Main Node Dispatcher //
2316
+ // ========================================================================== //
2317
+
2318
+ /**
2319
+ * Dispatches the current token to the appropriate specialized parser function.
2320
+ *
2321
+ * @param {Object[]} tokens - Token stream.
2322
+ * @param {number} i - Initial index.
2323
+ * @param {string|null} filename - Source filename.
2324
+ * @param {Object} placeholders - Dynamic public API data.
2325
+ * @returns {[Object, number]} The parsed node and new index.
2326
+ */
2327
+ function parseNode(tokens, i, filename = null, placeholders = {}, variables = {}, depth = 0) {
2328
+ if (!current_token(tokens, i) || (current_token(tokens, i) && !current_token(tokens, i).value)) {
2329
+ return [null, i];
2330
+ }
2331
+ // ========================================================================== //
2332
+ // Comment //
2333
+ // ========================================================================== //
2334
+ if (current_token(tokens, i) && (current_token(tokens, i).type === TOKEN_TYPES.COMMENT || current_token(tokens, i).type === TOKEN_TYPES.COMMENT_BLOCK)) {
2335
+ return parseCommentNode(tokens, i, depth);
2336
+ }
2337
+ // ========================================================================== //
2338
+ // Block or Reserved Keyword //
2339
+ // ========================================================================== //
2340
+ else if (current_token(tokens, i) && (current_token(tokens, i).type === TOKEN_TYPES.OPEN_BRACKET)) {
2341
+ return parseBlock(tokens, i, filename, placeholders, variables, depth);
2342
+ }
2343
+ // ========================================================================== //
2344
+ // Inline Statement or Text //
2345
+ // ========================================================================== //
2346
+ else if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.OPEN_PAREN) {
2347
+ let j = i + 1;
2348
+ let parenCount = 1;
2349
+ let foundArrow = false;
2350
+ while (j < tokens.length) {
2351
+ const token = tokens[j];
2352
+ if (token.type === TOKEN_TYPES.OPEN_PAREN) {
2353
+ parenCount++;
2354
+ } else if (token.type === TOKEN_TYPES.CLOSE_PAREN) {
2355
+ parenCount--;
2356
+ }
2357
+
2358
+ if (parenCount === 0) {
2359
+ const nextIdx = skipJunk(tokens, j + 1);
2360
+ if (tokens[nextIdx] && tokens[nextIdx].type === TOKEN_TYPES.THIN_ARROW) {
2361
+ foundArrow = true;
2362
+ }
2363
+ break;
2364
+ }
2365
+ // Safe-guard: If we hit a [ or @, it's highly unlikely to be an inline statement content
2366
+ // unless it's escaped, but lexer already handles [ and @ as structural tokens if not escaped.
2367
+ if (token.type === TOKEN_TYPES.OPEN_BRACKET || token.type === TOKEN_TYPES.OPEN_AT) break;
2368
+ j++;
2369
+ }
2370
+
2371
+ if (foundArrow) {
2372
+ return parseInline(tokens, i, placeholders, depth);
2373
+ }
2374
+
2375
+ // Treat as text if not an inline
2376
+ const textNode = makeTextNode();
2377
+ textNode.text = current_token(tokens, i).value;
2378
+ textNode.depth = depth;
2379
+ textNode.range = current_token(tokens, i).range;
2380
+ return [textNode, i + 1];
2381
+ }
2382
+ // ========================================================================== //
2383
+ // Logic Block //
2384
+ // ========================================================================== //
2385
+ else if (current_token(tokens, i) && (current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.LOGIC)) {
2386
+ let isStatic = current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD;
2387
+ let isRuntimeKeyword = current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD;
2388
+ let startRange = current_token(tokens, i).range;
2389
+ let nextI = i;
2390
+
2391
+ if (isStatic || isRuntimeKeyword) {
2392
+ nextI = skipJunk(tokens, i + 1);
2393
+ if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC) {
2394
+ // Treat as normal text if keyword is not followed by a logic block
2395
+ return parseText(tokens, i, placeholders, variables, depth);
2396
+ }
2397
+ i = nextI;
2398
+ }
2399
+
2400
+ const logicToken = current_token(tokens, i);
2401
+ const node = makeLogicNode(isStatic ? STATIC_LOGIC : RUNTIME_LOGIC);
2402
+ node.code = logicToken.value;
2403
+ node.depth = depth;
2404
+ node.range = {
2405
+ start: (isStatic || isRuntimeKeyword) ? startRange.start : logicToken.range.start,
2406
+ end: logicToken.range.end
2407
+ };
2408
+
2409
+ return [node, i + 1];
2410
+ }
2411
+ // ========================================================================== //
2412
+ // Text or Placeholder //
2413
+ // ========================================================================== //
2414
+ else if (
2415
+ current_token(tokens, i) &&
2416
+ (current_token(tokens, i).type === TOKEN_TYPES.TEXT ||
2417
+ current_token(tokens, i).type === TOKEN_TYPES.WHITESPACE ||
2418
+ current_token(tokens, i).type === TOKEN_TYPES.ESCAPE ||
2419
+ current_token(tokens, i).type === TOKEN_TYPES.VALUE ||
2420
+ current_token(tokens, i).type === TOKEN_TYPES.PREFIX_V ||
2421
+ current_token(tokens, i).type === TOKEN_TYPES.PREFIX_P)
2422
+ ) {
2423
+ return parseText(tokens, i, placeholders, variables, depth);
2424
+ }
2425
+ // ========================================================================== //
2426
+ // Atblock //
2427
+ // ========================================================================== //
2428
+ else if (current_token(tokens, i) && (current_token(tokens, i).type === TOKEN_TYPES.OPEN_AT)) {
2429
+ return parseAtBlock(tokens, i, filename, placeholders, depth);
2430
+ } else {
2431
+ // FALLBACK: Treat any other token as TEXT to avoid infinite loops and allow literal content
2432
+ const textNode = makeTextNode();
2433
+ textNode.text = current_token(tokens, i).value;
2434
+ textNode.depth = depth;
2435
+ textNode.range = current_token(tokens, i).range;
2436
+ return [textNode, i + 1];
2437
+ }
2438
+ }
2439
+
2440
+ // ========================================================================== //
2441
+ // Main Parser Entry Point //
2442
+ // ========================================================================== //
2443
+
2444
+ /**
2445
+ * SomMark Parser Entry Point.
2446
+ *
2447
+ * Orchestrates the recursive descent parsing of the token stream into a
2448
+ * hierarchical Abstract Syntax Tree (AST).
2449
+ *
2450
+ * @param {Object[]} tokens - The stream of tokens from the Lexer.
2451
+ * @param {string|null} [filename=null] - Source filename for error context.
2452
+ * @param {Object} [placeholders={}] - Global data for p{keyword} resolution.
2453
+ * @param {Object} [variables={}] - Local data for v{keyword} resolution.
2454
+ * @returns {Array<Object>} The final Abstract Syntax Tree.
2455
+ */
2456
+ function parser(tokens, filename = null, placeholders = {}, variables = {}) {
2457
+ end_stack = [];
2458
+ let ast = [];
2459
+ let i = 0;
2460
+ while (i < tokens.length) {
2461
+ let [node, nextIndex] = parseNode(tokens, i, filename, placeholders, variables, 1);
2462
+ if (node) {
2463
+ ast.push(node);
2464
+ i = nextIndex;
2465
+ } else {
2466
+ i++;
2467
+ }
2468
+ }
2469
+ if (end_stack.length !== 0) {
2470
+ let extraInfo = "";
2471
+
2472
+ const checkTypo = (token) => {
2473
+ if (token && token.value) {
2474
+ const val = token.value.trim().toLowerCase();
2475
+ if (val === "") return "";
2476
+ const dist = levenshtein(val, "end");
2477
+ if (dist > 0 && dist <= 2) return ` (Did you mean <$cyan:'[end]'$>?)`;
2478
+ }
2479
+ return "";
2480
+ };
2481
+
2482
+ // Check last few tokens for a typo
2483
+ for (let j = 1; j <= 5; j++) {
2484
+ const token = tokens[tokens.length - j];
2485
+ if (!token) break;
2486
+ extraInfo = checkTypo(token);
2487
+ if (extraInfo) break;
2488
+ }
2489
+
2490
+ const lastOpen = end_stack[end_stack.length - 1];
2491
+ parserError(errorMessage(tokens, tokens.length - 1, "[end]", "", extraInfo ? `Missing '[end]' for block '${lastOpen.id}' (opened at line ${lastOpen.line}, col ${lastOpen.col})${extraInfo}` : `Missing '[end]' for block '${lastOpen.id}' (opened at line ${lastOpen.line}, col ${lastOpen.col})`, filename));
2492
+ }
2493
+ return ast;
2494
+ }
2495
+
2496
+ const lexSync = (src, filename = "anonymous") => {
2497
+ if (src === undefined || src === null) {
2498
+ runtimeError([`{line}<$red:Missing Source:$> <$yellow:The 'src' argument is required for tokenization.$>{line}`]);
2499
+ }
2500
+ if (typeof src !== "string") {
2501
+ runtimeError([`{line}<$red:Invalid Source Type:$> <$yellow:The 'src' argument must be a string, received ${typeof src}.$>{line}`]);
2502
+ }
2503
+ return lexer(src, filename);
2504
+ };
2505
+
2506
+ const lex = async (src, filename = "anonymous") => lexSync(src, filename);
2507
+
2508
+ const parseSync = (src, filename = "anonymous") => {
2509
+ if (src === undefined || src === null) {
2510
+ runtimeError([`{line}<$red:Missing Source:$> <$yellow:The 'src' argument is required for parsing.$>{line}`]);
2511
+ }
2512
+ if (typeof src !== "string") {
2513
+ runtimeError([`{line}<$red:Invalid Source Type:$> <$yellow:The 'src' argument must be a string, received ${typeof src}.$>{line}`]);
2514
+ }
2515
+ const tokens = lexer(src, filename);
2516
+ return parser(tokens, filename);
2517
+ };
2518
+
2519
+ const parse = async (src, filename = "anonymous") => parseSync(src, filename);
2520
+
2521
+ export { TOKEN_TYPES, labels, lex, lexSync, parse, parseSync };