devabhasha 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.js ADDED
@@ -0,0 +1,687 @@
1
+ // parser.js — builds an AST from the token stream.
2
+ // Statements: recursive descent. Expressions: Pratt (precedence climbing).
3
+
4
+ import { analyze } from './vibhakti.js';
5
+ import { KARAKA_TO_SLOT, TAG_STEMS, EVENT_STEMS } from './karaka-web.js';
6
+ import { KEYWORDS } from './keywords.js';
7
+ import { DevabhashaError } from './errors.js';
8
+ import { tokenize } from './lexer.js';
9
+
10
+ // Set of token types produced by keywords — these may still be used as
11
+ // property names after a dot (property namespace is separate).
12
+ const KEYWORD_TOKENS = new Set(Object.values(KEYWORDS));
13
+
14
+ export function parse(tokens) {
15
+ let pos = 0;
16
+
17
+ const peek = (k = 0) => tokens[pos + k];
18
+ const next = () => tokens[pos++];
19
+ const check = (type, value) =>
20
+ peek().type === type && (value === undefined || peek().value === value);
21
+
22
+ function expect(type, value) {
23
+ if (!check(type, value)) {
24
+ const t = peek();
25
+ throw new DevabhashaError(
26
+ `expected ${value ?? type} but found '${t.value}' (${t.type})`,
27
+ { line: t.line, col: t.col, kind: 'parse' }
28
+ );
29
+ }
30
+ return next();
31
+ }
32
+
33
+ // optional statement terminator
34
+ function eatSemi() {
35
+ while (check('SEMI')) next();
36
+ }
37
+
38
+ // ---- statements ----------------------------------------------------
39
+
40
+ function parseProgram() {
41
+ const body = [];
42
+ while (!check('EOF')) {
43
+ eatSemi();
44
+ if (check('EOF')) break;
45
+ body.push(parseStatement());
46
+ eatSemi();
47
+ }
48
+ return { type: 'Program', body };
49
+ }
50
+
51
+ function parseStatement() {
52
+ const startTok = peek();
53
+ const node = parseStatementInner();
54
+ // stamp source position (line/col of the statement's first token) so the
55
+ // codegen can emit a source map. Non-invasive: statement granularity only.
56
+ if (node && node.line == null && startTok) {
57
+ node.line = startTok.line;
58
+ node.col = startTok.col;
59
+ }
60
+ return node;
61
+ }
62
+
63
+ function parseStatementInner() {
64
+ if (check('LET') || check('CONST')) return parseVarDecl();
65
+ if (check('FUNC')) return parseFuncDecl(false);
66
+ if (check('ASYNC')) {
67
+ next(); // ASYNC
68
+ if (!check('FUNC')) {
69
+ throw new DevabhashaError('असमकालिकदोषः: असमकालिक must be followed by कार्य',
70
+ { line: peek().line, col: peek().col, kind: 'parse' });
71
+ }
72
+ return parseFuncDecl(true);
73
+ }
74
+ if (check('RETURN')) return parseReturn();
75
+ if (check('IF')) return parseIf();
76
+ if (check('WHILE')) return parseWhile();
77
+ if (check('FOR')) return parseFor();
78
+ if (check('BREAK')) { next(); return { type: 'Break' }; }
79
+ if (check('CONTINUE')) { next(); return { type: 'Continue' }; }
80
+ if (check('PRINT')) return parsePrint();
81
+ if (check('STYLENAME')) return parseStyleName();
82
+ if (check('STATE')) return parseStateDecl();
83
+ if (check('VIEW')) return parseView();
84
+ if (check('EXPORT')) return parseExport();
85
+ if (check('IMPORT')) return parseImport();
86
+ if (check('OP', '{')) return parseBlock();
87
+ // expression statement
88
+ const expr = parseExpression();
89
+ return { type: 'ExpressionStatement', expression: expr };
90
+ }
91
+
92
+ function parseBlock() {
93
+ expect('OP', '{');
94
+ const body = [];
95
+ while (!check('OP', '}') && !check('EOF')) {
96
+ eatSemi();
97
+ if (check('OP', '}')) break;
98
+ body.push(parseStatement());
99
+ eatSemi();
100
+ }
101
+ expect('OP', '}');
102
+ return { type: 'Block', body };
103
+ }
104
+
105
+ function parseVarDecl() {
106
+ const kind = next().type; // LET | CONST
107
+ const nt = expect('IDENT');
108
+ let init = null;
109
+ if (check('OP', '=')) { next(); init = parseExpression(); }
110
+ return { type: 'VarDecl', kind, name: nt.value, init, namePos: { line: nt.line, col: nt.col } };
111
+ }
112
+
113
+ function parseFuncDecl(isAsync = false) {
114
+ next(); // FUNC
115
+ const nt = expect('IDENT');
116
+ const params = parseParams();
117
+ const body = parseBlock();
118
+ return { type: 'FuncDecl', name: nt.value, params, body, async: isAsync,
119
+ namePos: { line: nt.line, col: nt.col }, paramPos: params.__pos };
120
+ }
121
+
122
+ function parseParams() {
123
+ expect('OP', '(');
124
+ const params = [];
125
+ const pos = [];
126
+ while (!check('OP', ')')) {
127
+ const pt = expect('IDENT');
128
+ params.push(pt.value);
129
+ pos.push({ line: pt.line, col: pt.col });
130
+ if (check('OP', ',')) next();
131
+ }
132
+ expect('OP', ')');
133
+ Object.defineProperty(params, '__pos', { value: pos, enumerable: false });
134
+ return params;
135
+ }
136
+
137
+ function parseReturn() {
138
+ next(); // RETURN
139
+ let arg = null;
140
+ if (!check('SEMI') && !check('OP', '}') && !check('EOF')) {
141
+ arg = parseExpression();
142
+ }
143
+ return { type: 'Return', argument: arg };
144
+ }
145
+
146
+ function parseIf() {
147
+ next(); // IF
148
+ expect('OP', '(');
149
+ const test = parseExpression();
150
+ expect('OP', ')');
151
+ const consequent = parseBlock();
152
+ let alternate = null;
153
+ if (check('ELSE')) {
154
+ next();
155
+ alternate = check('IF') ? parseIf() : parseBlock();
156
+ }
157
+ return { type: 'If', test, consequent, alternate };
158
+ }
159
+
160
+ function parseWhile() {
161
+ next(); // WHILE
162
+ expect('OP', '(');
163
+ const test = parseExpression();
164
+ expect('OP', ')');
165
+ const body = parseBlock();
166
+ return { type: 'While', test, body };
167
+ }
168
+
169
+ // प्रत्येकम् (वस्तु : समूह) { ... } → for (const वस्तु of समूह)
170
+ function parseFor() {
171
+ next(); // FOR
172
+ expect('OP', '(');
173
+ const it = expect('IDENT');
174
+ expect('OP', ':');
175
+ const iterable = parseExpression();
176
+ expect('OP', ')');
177
+ const body = parseBlock();
178
+ return { type: 'ForOf', item: it.value, iterable, body, namePos: { line: it.line, col: it.col } };
179
+ }
180
+
181
+ function parsePrint() {
182
+ next(); // PRINT
183
+ expect('OP', '(');
184
+ const args = [];
185
+ while (!check('OP', ')')) {
186
+ args.push(parseExpression());
187
+ if (check('OP', ',')) next();
188
+ }
189
+ expect('OP', ')');
190
+ return { type: 'Print', args };
191
+ }
192
+
193
+ // ---- expressions (Pratt) ------------------------------------------
194
+
195
+ const BINARY_PREC = {
196
+ '??': 1,
197
+ '||': 1, '&&': 2,
198
+ '==': 3, '!=': 3, '===': 3, '!==': 3,
199
+ '<': 4, '>': 4, '<=': 4, '>=': 4,
200
+ '+': 5, '-': 5,
201
+ '*': 6, '/': 6, '%': 6,
202
+ };
203
+
204
+ // compound assignment operators → the underlying binary op
205
+ const COMPOUND = {
206
+ '+=': '+', '-=': '-', '*=': '*', '/=': '/', '%=': '%',
207
+ };
208
+
209
+ function parseExpression() {
210
+ return parseAssignment();
211
+ }
212
+
213
+ function parseAssignment() {
214
+ const left = parseOrElse();
215
+
216
+ // compound assignment: x += y → x = x + y
217
+ const tok = peek();
218
+ if (tok.type === 'OP' && COMPOUND[tok.value]) {
219
+ const op = COMPOUND[tok.value];
220
+ next();
221
+ const right = parseAssignment(); // RHS may itself be assignment/ternary
222
+ if (left.type !== 'Identifier' && left.type !== 'Member') {
223
+ throw new DevabhashaError('अमान्यं नियोजनम् (invalid assignment target)', { line: peek().line, col: peek().col, kind: 'parse' });
224
+ }
225
+ return {
226
+ type: 'Assign',
227
+ target: left,
228
+ value: { type: 'Binary', op, left, right },
229
+ };
230
+ }
231
+
232
+ if (check('OP', '=')) {
233
+ next();
234
+ const right = parseAssignment();
235
+ if (left.type !== 'Identifier' && left.type !== 'Member') {
236
+ throw new DevabhashaError('अमान्यं नियोजनम् (invalid assignment target)', { line: peek().line, col: peek().col, kind: 'parse' });
237
+ }
238
+ return { type: 'Assign', target: left, value: right };
239
+ }
240
+ return left;
241
+ }
242
+
243
+ // result अथवा fallback — yield the Result's मूल्यम् if सफल, else the fallback.
244
+ // Sits just below assignment, above ternary, and is right-associative so
245
+ // chains read left-to-right: अ अथवा ब अथवा ग = अ अथवा (ब अथवा ग).
246
+ function parseOrElse() {
247
+ const left = parseTernary();
248
+ if (check('ORELSE')) {
249
+ next();
250
+ const fallback = parseOrElse();
251
+ return { type: 'OrElse', value: left, fallback };
252
+ }
253
+ return left;
254
+ }
255
+
256
+ // ternary: परीक्षा ? तदा : अन्यथा (sits between assignment and binary)
257
+ function parseTernary() {
258
+ const cond = parseBinary(0);
259
+ if (check('OP', '?')) {
260
+ next();
261
+ const consequent = parseAssignment();
262
+ expect('OP', ':');
263
+ const alternate = parseAssignment();
264
+ return { type: 'Ternary', test: cond, consequent, alternate };
265
+ }
266
+ return cond;
267
+ }
268
+
269
+ function parseBinary(minPrec) {
270
+ let left = parseUnary();
271
+ while (check('OP') && BINARY_PREC[peek().value] !== undefined) {
272
+ const op = peek().value;
273
+ const prec = BINARY_PREC[op];
274
+ if (prec < minPrec) break;
275
+ next();
276
+ const right = parseBinary(prec + 1);
277
+ left = { type: 'Binary', op, left, right };
278
+ }
279
+ return left;
280
+ }
281
+
282
+ function parseUnary() {
283
+ if (check('OP', '!') || check('OP', '-')) {
284
+ const op = next().value;
285
+ return { type: 'Unary', op, argument: parseUnary() };
286
+ }
287
+ // प्रतीक्षा <expr> — await a promise (only valid in an async function)
288
+ if (check('AWAIT')) {
289
+ next();
290
+ return { type: 'Await', argument: parseUnary() };
291
+ }
292
+ return parsePostfix();
293
+ }
294
+
295
+ // calls, member access, indexing
296
+ function parsePostfix() {
297
+ let node = parsePrimary();
298
+ while (true) {
299
+ if (check('OP', '(')) {
300
+ next();
301
+ const args = [];
302
+ while (!check('OP', ')')) {
303
+ args.push(parseExpression());
304
+ if (check('OP', ',')) next();
305
+ }
306
+ expect('OP', ')');
307
+ node = { type: 'Call', callee: node, args };
308
+ } else if (check('OP', '.')) {
309
+ next();
310
+ // Property names live in their own namespace: a word that is a
311
+ // keyword elsewhere (e.g. योजय = MOUNT) is still a valid property
312
+ // name here (योजय = array push). Accept any word token's value.
313
+ const t = peek();
314
+ if (t.type === 'IDENT' || KEYWORD_TOKENS.has(t.type)) {
315
+ next();
316
+ node = { type: 'Member', object: node, property: t.value, computed: false };
317
+ } else {
318
+ throw new DevabhashaError(`expected property name after '.' but found '${t.value}'`, { line: t.line, col: t.col, kind: 'parse' });
319
+ }
320
+ } else if (check('OP', '[')) {
321
+ next();
322
+ const prop = parseExpression();
323
+ expect('OP', ']');
324
+ node = { type: 'Member', object: node, property: prop, computed: true };
325
+ } else break;
326
+ }
327
+ // postfix ++ / -- : x++ → Update node
328
+ if (check('OP', '++') || check('OP', '--')) {
329
+ const op = next().value;
330
+ if (node.type !== 'Identifier' && node.type !== 'Member') {
331
+ throw new DevabhashaError('++/-- needs a variable target', { line: peek().line, col: peek().col, kind: 'parse' });
332
+ }
333
+ node = { type: 'Update', op, target: node, prefix: false };
334
+ }
335
+ return node;
336
+ }
337
+
338
+ function parsePrimary() {
339
+ if (check('NUMBER')) return { type: 'Number', value: next().value };
340
+ if (check('STRING')) return { type: 'String', value: next().value };
341
+ // सूत्र(expr) — a reactive reference: captures expr unevaluated (a live
342
+ // thread to the cells it reads) so a component can bind it fine-grained.
343
+ if (check('SUTRA')) {
344
+ next();
345
+ expect('OP', '(');
346
+ const expr = parseExpression();
347
+ expect('OP', ')');
348
+ return { type: 'Sutra', expr };
349
+ }
350
+ if (check('TEMPLATE')) {
351
+ const t = next();
352
+ // parse each embedded expression source into an AST
353
+ const parts = t.exprs.map(srcExpr => {
354
+ const sub = parse(tokenize(srcExpr));
355
+ if (!sub.body.length || sub.body[0].type !== 'ExpressionStatement') {
356
+ throw new DevabhashaError('अन्तर्न्यासदोषः: {…} must contain a single expression',
357
+ { line: t.line, col: t.col, kind: 'parse' });
358
+ }
359
+ return sub.body[0].expression;
360
+ });
361
+ return { type: 'Template', chunks: t.chunks, parts };
362
+ }
363
+ if (check('TRUE')) { next(); return { type: 'Boolean', value: true }; }
364
+ if (check('FALSE')) { next(); return { type: 'Boolean', value: false }; }
365
+ if (check('NULL')) { next(); return { type: 'Null' }; }
366
+ if (check('IDENT')) {
367
+ const t = next();
368
+ return { type: 'Identifier', name: t.value, line: t.line, col: t.col };
369
+ }
370
+
371
+ // anonymous function expression: कार्य (params) { ... }
372
+ if (check('FUNC')) {
373
+ next();
374
+ const params = parseParams();
375
+ const body = parseBlock();
376
+ return { type: 'FuncExpr', params, body, async: false };
377
+ }
378
+ // async function expression: असमकालिक कार्य (params) { ... }
379
+ if (check('ASYNC')) {
380
+ next();
381
+ if (!check('FUNC')) {
382
+ throw new DevabhashaError('असमकालिकदोषः: असमकालिक must be followed by कार्य',
383
+ { line: peek().line, col: peek().col, kind: 'parse' });
384
+ }
385
+ next(); // FUNC
386
+ const params = parseParams();
387
+ const body = parseBlock();
388
+ return { type: 'FuncExpr', params, body, async: true };
389
+ }
390
+
391
+ // web-layer builtins as expressions
392
+ if (check('ELEMENT')) return parseElement();
393
+ if (check('MOUNT')) return parseBuiltinCall('Mount');
394
+ if (check('LISTEN')) return parseBuiltinCall('Listen');
395
+ if (check('CONSTRUCT')) return parseConstruct();
396
+ if (check('OBJECT')) return parseObjectLiteral();
397
+
398
+ // array literal
399
+ if (check('OP', '[')) {
400
+ next();
401
+ const elements = [];
402
+ while (!check('OP', ']')) {
403
+ elements.push(parseExpression());
404
+ if (check('OP', ',')) next();
405
+ }
406
+ expect('OP', ']');
407
+ return { type: 'Array', elements };
408
+ }
409
+
410
+ // grouping
411
+ if (check('OP', '(')) {
412
+ next();
413
+ const e = parseExpression();
414
+ expect('OP', ')');
415
+ return e;
416
+ }
417
+
418
+ const t = peek();
419
+ throw new DevabhashaError(`अनपेक्षितम् (unexpected) '${t.value}' (${t.type})`, { line: t.line, col: t.col, kind: 'parse' });
420
+ }
421
+
422
+ // अङ्गम्("div", "नमस्ते", [...children])
423
+ function parseElement() {
424
+ next(); // ELEMENT
425
+ expect('OP', '(');
426
+ const args = [];
427
+ while (!check('OP', ')')) {
428
+ args.push(parseExpression());
429
+ if (check('OP', ',')) next();
430
+ }
431
+ expect('OP', ')');
432
+ return { type: 'ElementExpr', args };
433
+ }
434
+
435
+ function parseBuiltinCall(kind) {
436
+ next();
437
+ expect('OP', '(');
438
+ const args = [];
439
+ while (!check('OP', ')')) {
440
+ args.push(parseExpression());
441
+ if (check('OP', ',')) next();
442
+ }
443
+ expect('OP', ')');
444
+ return { type: kind, args };
445
+ }
446
+
447
+ // ---- kāraka construction -------------------------------------------
448
+ //
449
+ // रचय <case-noun> [value] <case-noun> [value] …
450
+ //
451
+ // Each case-marked noun contributes a ROLE (from its vibhakti ending).
452
+ // Arguments may appear in ANY ORDER — they're collected into a slot map,
453
+ // not a positional list. A noun whose stem names a tag/event is
454
+ // self-valued; otherwise the following expression is its value.
455
+ function isCaseNoun(tok) {
456
+ if (!tok || tok.type !== 'IDENT') return null;
457
+ return analyze(tok.value); // {stem, case, karaka} | null
458
+ }
459
+
460
+ // Distinguish a रूप override block `{ वर्णः: … }` from a समास children
461
+ // block `{ रचय … }`. We're positioned at '{'. It's a style block iff the
462
+ // token after '{' is a name/string AND the one after that is ':'.
463
+ function looksLikeStyleBlock() {
464
+ const a = peek(1), b = peek(2);
465
+ if (!a || !b) return false;
466
+ const nameLike = a.type === 'IDENT' || a.type === 'STRING' || KEYWORD_TOKENS.has(a.type);
467
+ return nameLike && b.type === 'OP' && b.value === ':';
468
+ }
469
+
470
+ // Parse a { key: value, ... } style body into translated pairs.
471
+ // Assumes the current token is '{'.
472
+ function parseStylePairs() {
473
+ expect('OP', '{');
474
+ const pairs = [];
475
+ while (!check('OP', '}') && !check('EOF')) {
476
+ const t = peek();
477
+ let key;
478
+ if (t.type === 'STRING' || t.type === 'IDENT' || KEYWORD_TOKENS.has(t.type)) {
479
+ next();
480
+ key = t.value;
481
+ } else {
482
+ throw new DevabhashaError('रूपदोषः: style property must be a name', { line: t.line, col: t.col, kind: 'parse' });
483
+ }
484
+ expect('OP', ':');
485
+ const valTok = peek();
486
+ let value;
487
+ const loneWord = (valTok.type === 'IDENT' || KEYWORD_TOKENS.has(valTok.type)) &&
488
+ peek(1) && (peek(1).type === 'OP' && (peek(1).value === ',' || peek(1).value === '}'));
489
+ if (loneWord) {
490
+ next();
491
+ value = { kind: 'word', value: valTok.value };
492
+ } else {
493
+ value = { kind: 'expr', value: parseExpression() };
494
+ }
495
+ pairs.push({ key, value });
496
+ if (check('OP', ',')) next();
497
+ }
498
+ expect('OP', '}');
499
+ return pairs;
500
+ }
501
+
502
+ // रूपनाम X = रूप { ... } — declare a reusable named style.
503
+ // Desugars to a const binding whose value is the style object.
504
+ function parseStyleName() {
505
+ next(); // STYLENAME
506
+ const nt = expect('IDENT');
507
+ expect('OP', '=');
508
+ expect('STYLE');
509
+ const pairs = parseStylePairs();
510
+ return { type: 'StyleDecl', name: nt.value, pairs, namePos: { line: nt.line, col: nt.col } };
511
+ }
512
+
513
+ // भाव x = init — declare a reactive state cell.
514
+ function parseStateDecl() {
515
+ next(); // STATE
516
+ const nt = expect('IDENT');
517
+ let init = null;
518
+ if (check('OP', '=')) { next(); init = parseExpression(); }
519
+ return { type: 'StateDecl', name: nt.value, init, namePos: { line: nt.line, col: nt.col } };
520
+ }
521
+
522
+ // दृश्य { ... } or दृश्य (container) { ... }
523
+ // The block's final expression statement is the rendered view. With no
524
+ // container, the runtime mounts to the default root (stage/body).
525
+ function parseView() {
526
+ next(); // VIEW
527
+ let container = null;
528
+ if (check('OP', '(')) {
529
+ next();
530
+ container = parseExpression();
531
+ expect('OP', ')');
532
+ }
533
+ const body = parseBlock();
534
+ return { type: 'View', container, body };
535
+ }
536
+
537
+ // निर्यात <declaration> — mark a declaration as exported.
538
+ // निर्यात कार्य द्वि(न){ … }
539
+ // निर्यात नियत पाई = ३.१४।
540
+ // निर्यात रूपनाम कार्डः = रूप { … }।
541
+ function parseExport() {
542
+ next(); // EXPORT
543
+ const decl = parseStatement();
544
+ const exportable = new Set(['VarDecl', 'FuncDecl', 'StyleDecl', 'StateDecl']);
545
+ if (!exportable.has(decl.type)) {
546
+ throw new DevabhashaError('निर्यातदोषः: only declarations (चर/नियत/कार्य/रूपनाम/भाव) can be exported',
547
+ { line: peek().line, col: peek().col, kind: 'parse' });
548
+ }
549
+ // the exported binding's name
550
+ const name = decl.name;
551
+ return { type: 'Export', name, decl };
552
+ }
553
+
554
+ // आयात — three forms:
555
+ // आयात { द्वि, त्रि } आ "गणित"। named imports
556
+ // आयात * रूपेण ग आ "गणित"। namespace import (ग.द्वि …)
557
+ // आयात "उपकरणम्"। side-effect import (run the module)
558
+ function parseImport() {
559
+ next(); // IMPORT
560
+ // namespace: * रूपेण <name>
561
+ if (check('OP', '*')) {
562
+ next();
563
+ // रूपेण ("as") — accept an IDENT meaning "as"; we use आ-less alias via 'रूपेण'
564
+ const asTok = peek();
565
+ if (asTok.type === 'IDENT' && asTok.value === 'रूपेण') next();
566
+ const alias = expect('IDENT').value;
567
+ expect('FROM');
568
+ const source = expect('STRING').value;
569
+ return { type: 'Import', kind: 'namespace', alias, names: null, source };
570
+ }
571
+ // named: { a, b, c } आ "..."
572
+ if (check('OP', '{')) {
573
+ next();
574
+ const names = [];
575
+ while (!check('OP', '}') && !check('EOF')) {
576
+ names.push(expect('IDENT').value);
577
+ if (check('OP', ',')) next();
578
+ }
579
+ expect('OP', '}');
580
+ expect('FROM');
581
+ const source = expect('STRING').value;
582
+ return { type: 'Import', kind: 'named', names, alias: null, source };
583
+ }
584
+ // side-effect: आयात "..."
585
+ if (check('STRING')) {
586
+ const source = next().value;
587
+ return { type: 'Import', kind: 'effect', names: null, alias: null, source };
588
+ }
589
+ throw new DevabhashaError('आयातदोषः: expected { names } आ "module", * रूपेण name आ "module", or "module"',
590
+ { line: peek().line, col: peek().col, kind: 'parse' });
591
+ }
592
+
593
+ function parseConstruct() {
594
+ next(); // CONSTRUCT
595
+ const slots = {}; // slotName -> AST/value
596
+ const order = []; // record kāraka order purely for diagnostics
597
+
598
+ while (true) {
599
+ const tok = peek();
600
+ const a = isCaseNoun(tok);
601
+ if (!a) break; // no more case-marked arguments → construction ends
602
+
603
+ next(); // consume the case-marked noun
604
+ const slot = KARAKA_TO_SLOT[a.karaka];
605
+ order.push(a.karaka);
606
+
607
+ if (slot === 'tag' && TAG_STEMS[a.stem]) {
608
+ slots.tag = { type: 'String', value: TAG_STEMS[a.stem] };
609
+ } else if (slot === 'event' && EVENT_STEMS[a.stem]) {
610
+ slots.event = { type: 'String', value: EVENT_STEMS[a.stem] };
611
+ } else {
612
+ // role marker followed by its value expression
613
+ slots[slot] = parseExpression();
614
+ }
615
+ }
616
+
617
+ if (!slots.tag) {
618
+ throw new DevabhashaError('रचयदोषः: no कर्तृ (nominative) naming the element kind', { line: peek().line, col: peek().col, kind: 'parse' });
619
+ }
620
+
621
+ // रूप — style. Three forms:
622
+ // रूप { वर्णः: रक्तः } inline block
623
+ // रूप मुख्यपटः named-style reference (an identifier)
624
+ // रूप मुख्यपटः { अन्तरालः: … } named base + inline overrides
625
+ let style = null;
626
+ if (check('STYLE')) {
627
+ next();
628
+ let base = null;
629
+ if (check('IDENT')) {
630
+ base = { type: 'Identifier', name: next().value };
631
+ }
632
+ let pairs = [];
633
+ // A '{' here is style-overrides ONLY if it looks like key:value pairs
634
+ // (i.e. `{ word : ...`). Otherwise the '{' belongs to the समास children
635
+ // block — important after a bare named ref: रूप कार्डः { ...children }.
636
+ if (check('OP', '{') && looksLikeStyleBlock()) {
637
+ pairs = parseStylePairs();
638
+ }
639
+ style = { base, pairs };
640
+ }
641
+
642
+ // समास (compound) block form: a तत्पुरुष container whose body is a
643
+ // द्वन्द्व (sibling list) of child constructions and/or expressions.
644
+ let children = null;
645
+ if (check('OP', '{')) {
646
+ next();
647
+ children = [];
648
+ while (!check('OP', '}') && !check('EOF')) {
649
+ eatSemi();
650
+ if (check('OP', '}')) break;
651
+ // a child is either a nested रचय or any expression (text/value)
652
+ children.push(parseExpression());
653
+ eatSemi();
654
+ }
655
+ expect('OP', '}');
656
+ }
657
+
658
+ return { type: 'Construct', slots, order, children, style };
659
+ }
660
+
661
+ // कोष { कुञ्जी: मूल्यम्, अन्या: मूल्यम् } → object literal
662
+ function parseObjectLiteral() {
663
+ next(); // OBJECT
664
+ expect('OP', '{');
665
+ const props = [];
666
+ while (!check('OP', '}')) {
667
+ let key;
668
+ const t = peek();
669
+ // Keys live in their own namespace: a STRING, an IDENT, or any word
670
+ // that happens to be a keyword elsewhere (चर, यदि…) is a valid key.
671
+ if (t.type === 'STRING' || t.type === 'IDENT' || KEYWORD_TOKENS.has(t.type)) {
672
+ next();
673
+ key = { type: 'String', value: t.value };
674
+ } else {
675
+ throw new DevabhashaError('कोषदोषः: object key must be a name or string', { line: peek().line, col: peek().col, kind: 'parse' });
676
+ }
677
+ expect('OP', ':');
678
+ const value = parseExpression();
679
+ props.push({ key, value });
680
+ if (check('OP', ',')) next();
681
+ }
682
+ expect('OP', '}');
683
+ return { type: 'ObjectLiteral', props };
684
+ }
685
+
686
+ return parseProgram();
687
+ }