rip-lang 3.14.1 → 3.14.3

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/compiler.js CHANGED
@@ -198,6 +198,12 @@ export class CodeEmitter {
198
198
  'optcall': 'emitOptCall',
199
199
  'regex-index': 'emitRegexIndex',
200
200
 
201
+ // Pick operator — obj.{a, b: c, d = default}
202
+ // Heads are non-identifier shapes so they can't collide with a user
203
+ // function named `pick` (e.g. `pick = (x) -> ...; pick(false)`).
204
+ '.{}': 'emitPick',
205
+ '?.{}': 'emitOptPick',
206
+
201
207
  // Functions
202
208
  'def': 'emitDef',
203
209
  '->': 'emitThinArrow',
@@ -463,6 +469,12 @@ export class CodeEmitter {
463
469
  return;
464
470
  }
465
471
 
472
+ if (head === 'for-in' || head === 'for-of' || head === 'for-as') {
473
+ this.collectVarsFromLoopHead(rest[0], this.programVars);
474
+ rest.slice(1).forEach(item => this.collectProgramVariables(item));
475
+ return;
476
+ }
477
+
466
478
  if (head === 'def' || head === '->' || head === '=>' || head === 'effect') return;
467
479
 
468
480
  if (head === 'if') {
@@ -515,6 +527,11 @@ export class CodeEmitter {
515
527
  collect(value);
516
528
  return;
517
529
  }
530
+ if (head === 'for-in' || head === 'for-of' || head === 'for-as') {
531
+ this.collectVarsFromLoopHead(rest[0], vars);
532
+ rest.slice(1).forEach(collect);
533
+ return;
534
+ }
518
535
  if (head === 'def' || head === '->' || head === '=>' || head === 'effect') return;
519
536
  if (head === 'try') {
520
537
  collect(rest[0]);
@@ -1023,7 +1040,7 @@ export class CodeEmitter {
1023
1040
  this._componentName = prevComponentName;
1024
1041
  this._componentTypeParams = prevComponentTypeParams;
1025
1042
  this._schemaName = prevSchemaName;
1026
- let isObjLit = this.is(value, 'object');
1043
+ let isObjLit = this.is(value, 'object') || this.is(value, '.{}') || this.is(value, '?.{}');
1027
1044
  if (!isObjLit) valueCode = this.unwrap(valueCode);
1028
1045
 
1029
1046
  let needsParensVal = context === 'value';
@@ -1058,6 +1075,81 @@ export class CodeEmitter {
1058
1075
  return `${this.emit(obj, 'value')}?.${prop}`;
1059
1076
  }
1060
1077
 
1078
+ // Pick operator: obj.{a, b: c, d = default}
1079
+ //
1080
+ // Semantics:
1081
+ // - missing key → `undefined` (source.key just reads as undefined)
1082
+ // - default fires on nullish (`??`), deliberately broader than JS
1083
+ // destructure's undefined-only defaults, to match DB NULL reality.
1084
+ // - rename `a: b` emits `b: source.a` (inverse of destructure pattern)
1085
+ //
1086
+ // Codegen strategy:
1087
+ // - Simple source (bare identifier, `this`) → inline, no function alloc
1088
+ // - Complex source (call, member, indexed, etc.) → arrow IIFE binds
1089
+ // a single-letter temp to ensure single evaluation and avoid
1090
+ // repeating getter reads.
1091
+ //
1092
+ // AST: [".{}", source, [srcKey, dstKey, defaultOrNull], ...]
1093
+ emitPick(head, rest, context, sexpr) {
1094
+ let [source, ...items] = rest;
1095
+ let sourceCode = this.emit(source, 'value');
1096
+ let simple = this._isSimplePickSource(source);
1097
+ let ref = simple ? sourceCode : '_';
1098
+
1099
+ let body = items.map(([srcKey, dstKey, def]) => {
1100
+ let access = `${ref}.${str(srcKey)}`;
1101
+ if (def !== null && def !== undefined) {
1102
+ access = `(${access} ?? ${this.emit(def, 'value')})`;
1103
+ }
1104
+ return `${str(dstKey)}: ${access}`;
1105
+ }).join(', ');
1106
+
1107
+ // Always parenthesize the object literal so at statement position it
1108
+ // parses as an expression, not a block. `x = ({a:...})` is harmless;
1109
+ // bare `{a:...}` at statement-top would parse as a block in JS.
1110
+ if (simple) return `({${body}})`;
1111
+ return `((_) => ({${body}}))(${sourceCode})`;
1112
+ }
1113
+
1114
+ // Optional-chain pick: obj?.{a, b}
1115
+ // - If source is null/undefined, result is `undefined` (not `{}`)
1116
+ // - Otherwise identical to pick semantics above
1117
+ emitOptPick(head, rest, context, sexpr) {
1118
+ let [source, ...items] = rest;
1119
+ let sourceCode = this.emit(source, 'value');
1120
+ let simple = this._isSimplePickSource(source);
1121
+ let ref = simple ? sourceCode : '_';
1122
+
1123
+ let body = items.map(([srcKey, dstKey, def]) => {
1124
+ let access = `${ref}.${str(srcKey)}`;
1125
+ if (def !== null && def !== undefined) {
1126
+ access = `(${access} ?? ${this.emit(def, 'value')})`;
1127
+ }
1128
+ return `${str(dstKey)}: ${access}`;
1129
+ }).join(', ');
1130
+
1131
+ if (simple) return `(${sourceCode} == null ? undefined : {${body}})`;
1132
+ return `((_) => _ == null ? undefined : ({${body}}))(${sourceCode})`;
1133
+ }
1134
+
1135
+ // A pick source is "simple" only when it's safe to reference multiple
1136
+ // times with no observable difference from a single-evaluation form.
1137
+ // Restricted to AST shapes that are atomically identifier-like:
1138
+ // - bare identifier (AST is a plain string like "whom")
1139
+ // - `this` (AST is the literal string "this")
1140
+ // - `@` (AST is the literal string "@")
1141
+ // Member access like `this.x` or `obj.y` is NOT simple: getters and
1142
+ // reactive tracking can observe each read, so we force an IIFE to
1143
+ // evaluate the source exactly once.
1144
+ _isSimplePickSource(node) {
1145
+ if (typeof node !== 'string') return false;
1146
+ // Identifier-shape or `@`/`this` only. Rejects string-typed AST nodes
1147
+ // that happen to carry non-identifier content (defensive; current AST
1148
+ // doesn't produce such strings but future shape changes are bounded).
1149
+ return node === 'this' || node === '@' ||
1150
+ /^[A-Za-z_$][\w$]*$/.test(node);
1151
+ }
1152
+
1061
1153
  emitRegexIndex(head, rest) {
1062
1154
  let [value, regex, captureIndex] = rest;
1063
1155
  this.helpers.add('toMatchable');
@@ -1380,7 +1472,7 @@ export class CodeEmitter {
1380
1472
  let stmts = body.slice(1);
1381
1473
  this.indentLevel++;
1382
1474
  let lines = [];
1383
- if (!noVar) lines.push(`const ${itemVarPattern} = ${iterCode}[${idxName}];`);
1475
+ if (!noVar) lines.push(`${itemVarPattern} = ${iterCode}[${idxName}];`);
1384
1476
  if (guard) {
1385
1477
  lines.push(`if (${this.emit(guard, 'value')}) {`);
1386
1478
  this.indentLevel++;
@@ -1400,8 +1492,8 @@ export class CodeEmitter {
1400
1492
  : loopHeader + `{ ${this.emit(body, 'statement')}; }`;
1401
1493
  }
1402
1494
  return guard
1403
- ? loopHeader + `{ const ${itemVarPattern} = ${iterCode}[${idxName}]; if (${this.emit(guard, 'value')}) ${this.emit(body, 'statement')}; }`
1404
- : loopHeader + `{ const ${itemVarPattern} = ${iterCode}[${idxName}]; ${this.emit(body, 'statement')}; }`;
1495
+ ? loopHeader + `{ ${itemVarPattern} = ${iterCode}[${idxName}]; if (${this.emit(guard, 'value')}) ${this.emit(body, 'statement')}; }`
1496
+ : loopHeader + `{ ${itemVarPattern} = ${iterCode}[${idxName}]; ${this.emit(body, 'statement')}; }`;
1405
1497
  }
1406
1498
 
1407
1499
  // Index variable → traditional for loop
@@ -1411,7 +1503,7 @@ export class CodeEmitter {
1411
1503
  if (this.is(body, 'block')) {
1412
1504
  code += '{\n';
1413
1505
  this.indentLevel++;
1414
- code += this.indent() + `const ${itemVarPattern} = ${iterCode}[${indexVar}];\n`;
1506
+ code += this.indent() + `${itemVarPattern} = ${iterCode}[${indexVar}];\n`;
1415
1507
  if (guard) {
1416
1508
  code += this.indent() + `if (${this.unwrap(this.emit(guard, 'value'))}) {\n`;
1417
1509
  this.indentLevel++;
@@ -1425,8 +1517,8 @@ export class CodeEmitter {
1425
1517
  code += this.indent() + '}';
1426
1518
  } else {
1427
1519
  code += guard
1428
- ? `{ const ${itemVarPattern} = ${iterCode}[${indexVar}]; if (${this.unwrap(this.emit(guard, 'value'))}) ${this.emit(body, 'statement')}; }`
1429
- : `{ const ${itemVarPattern} = ${iterCode}[${indexVar}]; ${this.emit(body, 'statement')}; }`;
1520
+ ? `{ ${itemVarPattern} = ${iterCode}[${indexVar}]; if (${this.unwrap(this.emit(guard, 'value'))}) ${this.emit(body, 'statement')}; }`
1521
+ : `{ ${itemVarPattern} = ${iterCode}[${indexVar}]; ${this.emit(body, 'statement')}; }`;
1430
1522
  }
1431
1523
  return code;
1432
1524
  }
@@ -1450,7 +1542,8 @@ export class CodeEmitter {
1450
1542
  }
1451
1543
 
1452
1544
  // Default: for-of
1453
- let code = `for (const ${itemVarPattern} of ${this.emit(iterable, 'value')}) `;
1545
+ let bind = noVar ? 'let ' : '';
1546
+ let code = `for (${bind}${itemVarPattern} of ${this.emit(iterable, 'value')}) `;
1454
1547
  code += guard ? this.emitLoopBodyWithGuard(body, guard) : this.emitLoopBody(body);
1455
1548
  return code;
1456
1549
  }
@@ -1465,7 +1558,7 @@ export class CodeEmitter {
1465
1558
 
1466
1559
  let [keyVar, valueVar] = Array.isArray(vars) ? vars : [vars];
1467
1560
  let objCode = this.emit(obj, 'value');
1468
- let code = `for (const ${keyVar} in ${objCode}) `;
1561
+ let code = `for (${keyVar} in ${objCode}) `;
1469
1562
 
1470
1563
  if (own && !valueVar && !guard) {
1471
1564
  if (this.is(body, 'block')) {
@@ -1483,7 +1576,7 @@ export class CodeEmitter {
1483
1576
  this.indentLevel++;
1484
1577
  let lines = [];
1485
1578
  if (own) lines.push(`if (!Object.hasOwn(${objCode}, ${keyVar})) continue;`);
1486
- lines.push(`const ${valueVar} = ${objCode}[${keyVar}];`);
1579
+ lines.push(`${valueVar} = ${objCode}[${keyVar}];`);
1487
1580
  if (guard) {
1488
1581
  lines.push(`if (${this.emit(guard, 'value')}) {`);
1489
1582
  this.indentLevel++;
@@ -1498,7 +1591,7 @@ export class CodeEmitter {
1498
1591
  }
1499
1592
  let inline = '';
1500
1593
  if (own) inline += `if (!Object.hasOwn(${objCode}, ${keyVar})) continue; `;
1501
- inline += `const ${valueVar} = ${objCode}[${keyVar}]; `;
1594
+ inline += `${valueVar} = ${objCode}[${keyVar}]; `;
1502
1595
  if (guard) inline += `if (${this.emit(guard, 'value')}) `;
1503
1596
  inline += `${this.emit(body, 'statement')};`;
1504
1597
  return code + `{ ${inline} }`;
@@ -1545,7 +1638,7 @@ export class CodeEmitter {
1545
1638
  itemVarPattern = this.emitDestructuringPattern(firstVar);
1546
1639
  else itemVarPattern = firstVar;
1547
1640
 
1548
- let code = `for ${awaitKw}(const ${itemVarPattern} of ${iterCode}) `;
1641
+ let code = `for ${awaitKw}(${itemVarPattern} of ${iterCode}) `;
1549
1642
 
1550
1643
  if (needsTempVar && destructStmts.length > 0) {
1551
1644
  let stmts = this.unwrapBlock(body);
@@ -1919,16 +2012,17 @@ export class CodeEmitter {
1919
2012
  let header = isNeg
1920
2013
  ? `for (let ${idxN} = ${ic}.length - 1; ${idxN} >= 0; ${update})`
1921
2014
  : `for (let ${idxN} = 0; ${idxN} < ${ic}.length; ${update})`;
1922
- return { header, setup: noVar ? null : `const ${ivp} = ${ic}[${idxN}];` };
2015
+ return { header, setup: noVar ? null : `${ivp} = ${ic}[${idxN}];` };
1923
2016
  }
1924
2017
  if (indexVar) {
1925
2018
  let ic = this.emit(iterable, 'value');
1926
2019
  return {
1927
2020
  header: `for (let ${indexVar} = 0; ${indexVar} < ${ic}.length; ${indexVar}++)`,
1928
- setup: `const ${ivp} = ${ic}[${indexVar}];`,
2021
+ setup: `${ivp} = ${ic}[${indexVar}];`,
1929
2022
  };
1930
2023
  }
1931
- return { header: `for (const ${ivp} of ${this.emit(iterable, 'value')})`, setup: null };
2024
+ let bind = noVar ? 'let ' : '';
2025
+ return { header: `for (${bind}${ivp} of ${this.emit(iterable, 'value')})`, setup: null };
1932
2026
  }
1933
2027
 
1934
2028
  // Shared: parse a for-of (object) iterator and return { header, own, vv, oc, kvp }.
@@ -1938,7 +2032,7 @@ export class CodeEmitter {
1938
2032
  let kvp = (this.is(kv, 'array') || this.is(kv, 'object'))
1939
2033
  ? this.emitDestructuringPattern(kv) : kv;
1940
2034
  let oc = this.emit(iterable, 'value');
1941
- return { header: `for (const ${kvp} in ${oc})`, own, vv, oc, kvp };
2035
+ return { header: `for (${kvp} in ${oc})`, own, vv, oc, kvp };
1942
2036
  }
1943
2037
 
1944
2038
  // Shared: parse a for-as (iterator) spec and return { header }.
@@ -1947,7 +2041,7 @@ export class CodeEmitter {
1947
2041
  let [fv] = va;
1948
2042
  let ivp = (this.is(fv, 'array') || this.is(fv, 'object'))
1949
2043
  ? this.emitDestructuringPattern(fv) : fv;
1950
- return { header: `for ${isAwait ? 'await ' : ''}(const ${ivp} of ${this.emit(iterable, 'value')})` };
2044
+ return { header: `for ${isAwait ? 'await ' : ''}(${ivp} of ${this.emit(iterable, 'value')})` };
1951
2045
  }
1952
2046
 
1953
2047
  emitComprehension(head, rest, context) {
@@ -1974,7 +2068,7 @@ export class CodeEmitter {
1974
2068
  code += this.indent() + header + ' {\n';
1975
2069
  this.indentLevel++;
1976
2070
  if (own) code += this.indent() + `if (!Object.hasOwn(${oc}, ${kvp})) continue;\n`;
1977
- if (vv) code += this.indent() + `const ${vv} = ${oc}[${kvp}];\n`;
2071
+ if (vv) code += this.indent() + `${vv} = ${oc}[${kvp}];\n`;
1978
2072
  } else if (iterType === 'for-as') {
1979
2073
  let { header } = this._forAsHeader(vars, iterable, iter[3]);
1980
2074
  code += this.indent() + header + ' {\n';
@@ -2038,10 +2132,10 @@ export class CodeEmitter {
2038
2132
  if (iterType === 'for-of') {
2039
2133
  let [kv, vv] = vars;
2040
2134
  let oc = this.emit(iterable, 'value');
2041
- code += this.indent() + `for (const ${kv} in ${oc}) {\n`;
2135
+ code += this.indent() + `for (${kv} in ${oc}) {\n`;
2042
2136
  this.indentLevel++;
2043
2137
  if (own) code += this.indent() + `if (!Object.hasOwn(${oc}, ${kv})) continue;\n`;
2044
- if (vv) code += this.indent() + `const ${vv} = ${oc}[${kv}];\n`;
2138
+ if (vv) code += this.indent() + `${vv} = ${oc}[${kv}];\n`;
2045
2139
  }
2046
2140
  }
2047
2141
  for (let guard of guards) { code += this.indent() + `if (${this.emit(guard, 'value')}) {\n`; this.indentLevel++; }
@@ -2385,7 +2479,14 @@ export class CodeEmitter {
2385
2479
  if (typeof param === 'string') return param;
2386
2480
  if (param instanceof String) return param.valueOf();
2387
2481
  if (this.is(param, 'rest')) return `...${param[1]}`;
2388
- if (this.is(param, 'default')) return `${param[1]} = ${this.emit(param[2], 'value')}`;
2482
+ if (this.is(param, 'default')) {
2483
+ // `param[1]` is either a plain identifier string (e.g. `x = 5`) or a
2484
+ // destructuring pattern AST node (e.g. `{a, b} = {}`). Recurse via
2485
+ // `formatParam` so patterns emit as `{a, b}` / `[x, y]` instead of
2486
+ // being coerced to a string via `Array.prototype.toString`, which
2487
+ // produced the famous `(object,,a,a,,b,b = {})` mis-rendering.
2488
+ return `${this.formatParam(param[1])} = ${this.emit(param[2], 'value')}`;
2489
+ }
2389
2490
  if (this.is(param, '.') && param[1] === 'this') return param[2];
2390
2491
  if (this.is(param, 'array')) {
2391
2492
  let els = param.slice(1).map(el => {
@@ -2695,7 +2796,7 @@ export class CodeEmitter {
2695
2796
  code += header + ' {\n';
2696
2797
  this.indentLevel++;
2697
2798
  if (own) code += this.indent() + `if (!Object.hasOwn(${oc}, ${kvp})) continue;\n`;
2698
- if (vv) code += this.indent() + `const ${vv} = ${oc}[${kvp}];\n`;
2799
+ if (vv) code += this.indent() + `${vv} = ${oc}[${kvp}];\n`;
2699
2800
  emitBody();
2700
2801
  this.indentLevel--;
2701
2802
  code += this.indent() + '}';
@@ -3059,12 +3160,29 @@ export class CodeEmitter {
3059
3160
  if (typeof item === 'string') { varSet.add(item); return; }
3060
3161
  if (Array.isArray(item)) {
3061
3162
  if (item[0] === '...' && typeof item[1] === 'string') varSet.add(item[1]);
3163
+ else if (item[0] === '=' && typeof item[1] === 'string') varSet.add(item[1]);
3062
3164
  else if (item[0] === 'array') this.collectVarsFromArray(item, varSet);
3063
3165
  else if (item[0] === 'object') this.collectVarsFromObject(item, varSet);
3064
3166
  }
3065
3167
  });
3066
3168
  }
3067
3169
 
3170
+ // Collect names bound by a for-in / for-of / for-as head. `vars` is the
3171
+ // second slot of the loop s-expression and always arrives as an array of
3172
+ // entries — e.g. ['x'], ['x', 'i'], ['k', 'v'], [['array', 'a', 'b']],
3173
+ // or [undefined] for no-var range loops.
3174
+ collectVarsFromLoopHead(vars, varSet) {
3175
+ if (!Array.isArray(vars)) return;
3176
+ vars.forEach(v => {
3177
+ if (v == null) return;
3178
+ if (typeof v === 'string') { varSet.add(v); return; }
3179
+ if (Array.isArray(v)) {
3180
+ if (v[0] === 'array') this.collectVarsFromArray(v, varSet);
3181
+ else if (v[0] === 'object') this.collectVarsFromObject(v, varSet);
3182
+ }
3183
+ });
3184
+ }
3185
+
3068
3186
  collectVarsFromObject(obj, varSet) {
3069
3187
  obj.slice(1).forEach(pair => {
3070
3188
  if (!Array.isArray(pair)) return;
@@ -243,6 +243,13 @@ grammar =
243
243
  o 'Value INDEX_START INDENT Expression OUTDENT INDEX_END' , '["[]", 1, 4]'
244
244
  o 'Value INDEX_START Slice INDEX_END' , '["[]", 1, 3]'
245
245
  o 'Value INDEX_START INDENT Slice OUTDENT INDEX_END' , '["[]", 1, 4]'
246
+ # Pick operator: obj.{a, b: c, d = default} → { a: obj.a, c: obj.b, d: obj.d ?? default }
247
+ # Heads `.{}` and `?.{}` are syntax-shape strings that can't collide with
248
+ # a user function named `pick` (Rip encodes calls as [name, ...args]).
249
+ o 'Value PICK_START PickList OptComma PICK_END' , '[".{}", 1, ...3]'
250
+ o 'Value OPTPICK_START PickList OptComma PICK_END' , '["?.{}", 1, ...3]'
251
+ o 'Value PICK_START INDENT PickList OptComma OUTDENT PICK_END', '[".{}", 1, ...4]'
252
+ o 'Value OPTPICK_START INDENT PickList OptComma OUTDENT PICK_END', '["?.{}", 1, ...4]'
246
253
  # Regex indexing with capture group
247
254
  o 'Value INDEX_START RegexWithIndex INDEX_END' , '[$3[0], $1, ...$3.slice(1)]'
248
255
  # ES6 optional indexing (?.[ )
@@ -323,6 +330,11 @@ grammar =
323
330
  # Indexing
324
331
  o 'ObjSpreadExpr INDEX_START Expression INDEX_END' , '["[]", 1, 3]'
325
332
  o 'ObjSpreadExpr INDEX_START INDENT Expression OUTDENT INDEX_END', '["[]", 1, 4]'
333
+ # Pick — {...obj.{a, b}} inside object literal spread
334
+ o 'ObjSpreadExpr PICK_START PickList OptComma PICK_END' , '[".{}", 1, ...3]'
335
+ o 'ObjSpreadExpr OPTPICK_START PickList OptComma PICK_END' , '["?.{}", 1, ...3]'
336
+ o 'ObjSpreadExpr PICK_START INDENT PickList OptComma OUTDENT PICK_END', '[".{}", 1, ...4]'
337
+ o 'ObjSpreadExpr OPTPICK_START INDENT PickList OptComma OUTDENT PICK_END', '["?.{}", 1, ...4]'
326
338
  ]
327
339
 
328
340
  # ============================================================================
@@ -348,6 +360,35 @@ grammar =
348
360
  o '... Expression' , '["...", 2]'
349
361
  ]
350
362
 
363
+ # ============================================================================
364
+ # Pick Expressions — obj.{a, b: newB, c = 'default', d: newD = 'x'}
365
+ #
366
+ # Each item compiles to a 3-tuple [srcKey, dstKey, defaultOrNull] that
367
+ # the codegen emits as `dstKey: (source.srcKey ?? default)` — nullish,
368
+ # intentionally different from JS destructure defaults (undefined-only).
369
+ #
370
+ # PickKey is restricted to Identifier/Property so we don't admit strings,
371
+ # numbers, or computed keys in v1.
372
+ # ============================================================================
373
+
374
+ PickList: [
375
+ o 'PickItem' , '[1]'
376
+ o 'PickList , PickItem' , '[...1, 3]'
377
+ o 'PickList OptComma TERMINATOR PickItem', '[...1, 4]'
378
+ ]
379
+
380
+ PickItem: [
381
+ o 'PickKey' , '[1, 1, null]' # shorthand {a} → ["a", "a", null]
382
+ o 'PickKey : PickKey' , '[1, 3, null]' # rename {a: b} → ["a", "b", null]
383
+ o 'PickKey = Expression' , '[1, 1, 3]' # default {a = 5} → ["a", "a", 5]
384
+ o 'PickKey : PickKey = Expression' , '[1, 3, 5]' # both {a: b = 5} → ["a", "b", 5]
385
+ ]
386
+
387
+ PickKey: [
388
+ o 'Identifier'
389
+ o 'Property'
390
+ ]
391
+
351
392
  MapAssignable: [
352
393
  o 'Identifier'
353
394
  o 'Property'
package/src/lexer.js CHANGED
@@ -140,8 +140,8 @@ let IMPLICIT_COMMA_BEFORE_ARROW = new Set([
140
140
  ]);
141
141
 
142
142
  // Tokens that start/end balanced pairs
143
- let EXPRESSION_START = new Set(['(', '[', '{', 'MAP_START', 'INDENT', 'CALL_START', 'PARAM_START', 'INDEX_START', 'STRING_START', 'INTERPOLATION_START', 'REGEX_START']);
144
- let EXPRESSION_END = new Set([')', ']', '}', 'MAP_END', 'OUTDENT', 'CALL_END', 'PARAM_END', 'INDEX_END', 'STRING_END', 'INTERPOLATION_END', 'REGEX_END']);
143
+ let EXPRESSION_START = new Set(['(', '[', '{', 'MAP_START', 'PICK_START', 'OPTPICK_START', 'INDENT', 'CALL_START', 'PARAM_START', 'INDEX_START', 'STRING_START', 'INTERPOLATION_START', 'REGEX_START']);
144
+ let EXPRESSION_END = new Set([')', ']', '}', 'MAP_END', 'PICK_END', 'OUTDENT', 'CALL_END', 'PARAM_END', 'INDEX_END', 'STRING_END', 'INTERPOLATION_END', 'REGEX_END']);
145
145
 
146
146
  // Balanced pair inverses
147
147
  let INVERSES = {
@@ -156,6 +156,8 @@ let INVERSES = {
156
156
  'INTERPOLATION_START': 'INTERPOLATION_END', 'INTERPOLATION_END': 'INTERPOLATION_START',
157
157
  'REGEX_START': 'REGEX_END', 'REGEX_END': 'REGEX_START',
158
158
  'MAP_START': 'MAP_END', 'MAP_END': 'MAP_START',
159
+ 'PICK_START': 'PICK_END', 'PICK_END': 'PICK_START',
160
+ 'OPTPICK_START': 'PICK_END',
159
161
  };
160
162
 
161
163
  // Tokens that close a clause (for normalizeLines)
@@ -181,6 +183,25 @@ let SINGLE_CLOSERS = new Set(['TERMINATOR', 'CATCH', 'FINALLY', 'ELSE', 'OUTDENT
181
183
  // Tokens that indicate end-of-line
182
184
  let LINE_BREAK = new Set(['INDENT', 'OUTDENT', 'TERMINATOR']);
183
185
 
186
+ // Compound-key recognition shared by three lexer sites:
187
+ // - the implicit-call guard (blocks `IDENT -IDENT :` from wrapping as `IDENT(-IDENT)`)
188
+ // - the `:` handler (collapses the chain into a single STRING token)
189
+ // - `looksObjectish` (tells the implicit-object tracker an indented chain is still a key)
190
+ //
191
+ // A compound key is an IDENTIFIER followed by `[ (. | -) (IDENTIFIER|PROPERTY) ]+` and then `:`.
192
+ // `.` accepts any spacing (pre-existing behavior). `-` requires no whitespace *or*
193
+ // newline on either side, so legitimate subtraction (`a - b: 1`) and line-broken
194
+ // expressions (`foo-\n bar:`) remain unaffected.
195
+ let isIdentOrProp = t => t?.[0] === 'IDENTIFIER' || t?.[0] === 'PROPERTY';
196
+ let isCompoundSep = (sep, rhs) => {
197
+ if (sep?.[0] === '.') return true;
198
+ if (sep?.[0] === '-') {
199
+ return sep.spaced === false && !sep.newLine &&
200
+ rhs?.spaced === false && !rhs?.newLine;
201
+ }
202
+ return false;
203
+ };
204
+
184
205
  // Tokens that close implicit calls when following a newline
185
206
  let CALL_CLOSERS = new Set(['.', '?.']);
186
207
 
@@ -426,6 +447,47 @@ export class Lexer {
426
447
  return p ? p[1] : undefined;
427
448
  }
428
449
 
450
+ // True when the next identifier/keyword-shaped token would sit in a
451
+ // pick-key position of a `.{` or `?.{` body. Used by identifierToken()
452
+ // to tag reserved-word keys (`default`, `class`, `delete`, …) as
453
+ // PROPERTY so they don't later become keyword/UNARY tokens (which
454
+ // would suppress a following TERMINATOR in multi-line bodies).
455
+ //
456
+ // Pick-key position means prev is one of: `{` (first key), `,`
457
+ // (subsequent key), `:` (rename dest), TERMINATOR/INDENT/OUTDENT
458
+ // (newline-separated key), AND we're inside a `{` that was
459
+ // preceded by a tight `.` or `?.`.
460
+ inPickKeyPos() {
461
+ let prev = this.prev();
462
+ if (!prev) return false;
463
+ let p = prev[0];
464
+ if (p !== '{' && p !== ',' && p !== ':' &&
465
+ p !== 'TERMINATOR' && p !== 'INDENT' && p !== 'OUTDENT') return false;
466
+ // Walk back to find the enclosing `{`. Bail out on a few structural
467
+ // tokens that mean we've left any pick body.
468
+ let depth = 0;
469
+ let toks = this.tokens;
470
+ for (let k = toks.length - 1; k >= 0; k--) {
471
+ let t = toks[k];
472
+ let tt = t[0];
473
+ if (tt === '}' || tt === 'PICK_END') { depth++; continue; }
474
+ if (tt === '{' || tt === 'PICK_START' || tt === 'OPTPICK_START') {
475
+ if (depth === 0) {
476
+ let before = toks[k - 1];
477
+ return !!(before && (before[0] === '.' || before[0] === '?.') &&
478
+ !before.spaced && !t.spaced);
479
+ }
480
+ depth--;
481
+ continue;
482
+ }
483
+ // Cheap early-outs — if we escape the local block context, we
484
+ // clearly aren't inside any pick body.
485
+ if (depth === 0 && (tt === '(' || tt === 'CALL_START' ||
486
+ tt === '[' || tt === 'INDEX_START')) return false;
487
+ }
488
+ return false;
489
+ }
490
+
429
491
  // --------------------------------------------------------------------------
430
492
  // 1. Identifier Token
431
493
  // --------------------------------------------------------------------------
@@ -514,7 +576,7 @@ export class Lexer {
514
576
  if (colon && colon.length > 1 && /[a-zA-Z_$]/.test(this.chunk[idLen + colon.length])) colon = null;
515
577
 
516
578
  // Property vs identifier
517
- if (colon || (prev && (prev[0] === '.' || prev[0] === '?.' || (!prev.spaced && prev[0] === '@')))) {
579
+ if (colon || (prev && (prev[0] === '.' || prev[0] === '?.' || (!prev.spaced && prev[0] === '@'))) || this.inPickKeyPos()) {
518
580
  tag = 'PROPERTY';
519
581
 
520
582
  // In render blocks, consume hyphenated CSS class names: .counter-display → PROPERTY "counter-display"
@@ -1414,6 +1476,8 @@ export class Lexer {
1414
1476
  rewrite(tokens) {
1415
1477
  this.tokens = tokens;
1416
1478
  this.removeLeadingNewlines();
1479
+ this.rewriteDottedPicks(); // .{ and ?.{ — must run before map literals so
1480
+ // pick bodies see raw { } for depth counting
1417
1481
  this.rewriteMapLiterals();
1418
1482
  this.closeMergeAssignments();
1419
1483
  this.closeOpenCalls();
@@ -1457,6 +1521,52 @@ export class Lexer {
1457
1521
  }
1458
1522
  }
1459
1523
 
1524
+ // Pick operator: `obj.{a, b}` → { a: obj.a, b: obj.b }
1525
+ // `obj?.{a, b}` → obj == null ? undefined : { a: obj.a, b: obj.b }
1526
+ //
1527
+ // Recognize `.` or `?.` followed immediately by `{` at postfix position
1528
+ // (preceded by an INDEXABLE value). Retag the `.` away and mark the braces
1529
+ // as PICK_START/PICK_END (or OPTPICK_START/PICK_END for optional chain),
1530
+ // leaving the body tokens to parse via the Pick grammar.
1531
+ //
1532
+ // Runs before rewriteMapLiterals() so pick bodies see raw `{`/`}` for
1533
+ // depth tracking; map literals inside defaults are handled on the next
1534
+ // pass after pick braces have been distinguished.
1535
+ rewriteDottedPicks() {
1536
+ let tokens = this.tokens;
1537
+ for (let i = 0; i < tokens.length; i++) {
1538
+ let tag = tokens[i][0];
1539
+ if (tag !== '.' && tag !== '?.') continue;
1540
+ let next = tokens[i + 1];
1541
+ if (!next || next[0] !== '{') continue;
1542
+ // `.` must sit tight to the next `{`: no whitespace between them.
1543
+ // In Rip, token.spaced means "followed by whitespace", so checking
1544
+ // tokens[i].spaced (the `.`) catches `. {` cases. We DON'T check
1545
+ // next.spaced — that would mean "whitespace after `{`" which is
1546
+ // perfectly fine (`obj.{ a, b }` should work like `obj.{a, b}`).
1547
+ // Similarly, next.newLine is OK — it allows multiline bodies like
1548
+ // `obj.{\n a\n b\n}`.
1549
+ if (tokens[i].spaced || tokens[i].newLine) continue;
1550
+ let prev = tokens[i - 1];
1551
+ if (!prev || !INDEXABLE.has(prev[0])) continue;
1552
+ let optional = (tag === '?.');
1553
+ tokens.splice(i, 1);
1554
+ tokens[i][0] = optional ? 'OPTPICK_START' : 'PICK_START';
1555
+ // Find the matching close brace, retagging as PICK_END. Reserved-word
1556
+ // keys inside the body are already handled upstream by inPickKeyPos()
1557
+ // during initial tokenization — no second pass needed here.
1558
+ let depth = 1;
1559
+ for (let j = i + 1; j < tokens.length && depth > 0; j++) {
1560
+ let tt = tokens[j][0];
1561
+ if (tt === '{' || tt === 'PICK_START' || tt === 'OPTPICK_START') depth++;
1562
+ else if (tt === '}') {
1563
+ depth--;
1564
+ if (depth === 0) { tokens[j][0] = 'PICK_END'; break; }
1565
+ }
1566
+ }
1567
+ }
1568
+ }
1569
+
1460
1570
  closeOpenCalls() {
1461
1571
  this.scanTokens((token, i) => {
1462
1572
  if (token[0] === 'CALL_START') {
@@ -1708,7 +1818,20 @@ export class Lexer {
1708
1818
  }
1709
1819
 
1710
1820
  // Detect implicit function calls
1711
- if (IMPLICIT_FUNC.has(tag) && token.spaced &&
1821
+ //
1822
+ // Before firing on `IDENT -IDENT`, check if the real pattern is a
1823
+ // compound-key chain (`data-src:`). If so, let the `:` handler
1824
+ // collapse it to a STRING — don't wrap it as `data(-src)`.
1825
+ let looksLikeCompoundKey = false;
1826
+ if (nextTag === '-' && !nextToken.spaced && !nextToken.newLine && isIdentOrProp(token)) {
1827
+ for (let k = i; k < tokens.length - 2; k += 2) {
1828
+ if (!isCompoundSep(tokens[k + 1], tokens[k + 2])) break;
1829
+ if (!isIdentOrProp(tokens[k + 2])) break;
1830
+ if (tokens[k + 3]?.[0] === ':') { looksLikeCompoundKey = true; break; }
1831
+ }
1832
+ }
1833
+
1834
+ if (!looksLikeCompoundKey && IMPLICIT_FUNC.has(tag) && token.spaced &&
1712
1835
  (IMPLICIT_CALL.has(nextTag) || (nextTag === '...' && IMPLICIT_CALL.has(tokens[i + 2]?.[0])) ||
1713
1836
  (IMPLICIT_UNSPACED_CALL.has(nextTag) && !nextToken.spaced && !nextToken.newLine)) &&
1714
1837
  !((tag === ']' || tag === '}') && (nextTag === '->' || nextTag === '=>'))) {
@@ -1735,17 +1858,21 @@ export class Lexer {
1735
1858
  return forward(1);
1736
1859
  }
1737
1860
 
1738
- // Dotted keys: collapse `IDENTIFIER . PROPERTY . PROPERTY` into a STRING
1739
- if (tokens[i - 1]?.[0] === 'PROPERTY' && tokens[i - 2]?.[0] === '.') {
1861
+ // Compound keys: collapse `IDENTIFIER [ (. | -) (IDENTIFIER|PROPERTY) ]+` into a STRING.
1862
+ // Walks backwards from `:` through a chain, then rebuilds the string by concatenating
1863
+ // each token's raw value (identifiers carry safe characters; separators contribute
1864
+ // a literal `.` or `-`). See `isCompoundSep` for spacing rules.
1865
+ // Examples: `www.amazon.com:`, `data-src:`, `beta-site.amazon.com:`.
1866
+ if (isIdentOrProp(tokens[i - 1]) && isCompoundSep(tokens[i - 2], tokens[i - 1])) {
1740
1867
  let j = i - 2;
1741
- while (j >= 2 && tokens[j]?.[0] === '.' && (tokens[j - 1]?.[0] === 'PROPERTY' || tokens[j - 1]?.[0] === 'IDENTIFIER')) {
1868
+ while (j >= 2 && isCompoundSep(tokens[j], tokens[j + 1]) && isIdentOrProp(tokens[j - 1])) {
1742
1869
  j -= 2;
1743
1870
  }
1744
1871
  j += 1;
1745
- if (tokens[j]?.[0] === 'IDENTIFIER' || tokens[j]?.[0] === 'PROPERTY') {
1746
- let parts = [];
1747
- for (let k = j; k < i; k += 2) parts.push(tokens[k][1]);
1748
- let str = gen('STRING', `"${parts.join('.')}"`, tokens[j]);
1872
+ if (isIdentOrProp(tokens[j])) {
1873
+ let buf = '';
1874
+ for (let k = j; k < i; k++) buf += tokens[k][1];
1875
+ let str = gen('STRING', `"${buf}"`, tokens[j]);
1749
1876
  str.pre = tokens[j].pre;
1750
1877
  str.spaced = tokens[j].spaced;
1751
1878
  str.newLine = tokens[j].newLine;
@@ -1765,7 +1892,7 @@ export class Lexer {
1765
1892
  if (stackTop()) {
1766
1893
  let [stackTag, stackIdx] = stackTop();
1767
1894
  let stackNext = stack[stack.length - 2];
1768
- let isBrace = (t) => t === '{' || t === 'MAP_START';
1895
+ let isBrace = (t) => t === '{' || t === 'MAP_START' || t === 'PICK_START' || t === 'OPTPICK_START';
1769
1896
  if ((isBrace(stackTag) || (stackTag === 'INDENT' && isBrace(stackNext?.[0]) && !isImplicit(stackNext))) &&
1770
1897
  (startsLine || this.tokens[s - 1]?.[0] === ',' || isBrace(this.tokens[s - 1]?.[0]) || isBrace(this.tokens[s]?.[0]))) {
1771
1898
  return forward(1);
@@ -1943,11 +2070,13 @@ export class Lexer {
1943
2070
  if (!this.tokens[j]) return false;
1944
2071
  if (this.tokens[j]?.[0] === '@' && this.tokens[j + 2]?.[0] === ':') return true;
1945
2072
  if (this.tokens[j + 1]?.[0] === ':') return true;
1946
- // Dotted keys: IDENTIFIER . PROPERTY ... :
1947
- if ((this.tokens[j]?.[0] === 'IDENTIFIER' || this.tokens[j]?.[0] === 'PROPERTY') && this.tokens[j + 1]?.[0] === '.') {
1948
- let k = j + 2;
1949
- while (this.tokens[k]?.[0] === 'PROPERTY' && this.tokens[k + 1]?.[0] === '.') k += 2;
1950
- if (this.tokens[k]?.[0] === 'PROPERTY' && this.tokens[k + 1]?.[0] === ':') return true;
2073
+ // Compound keys: IDENTIFIER [ (. | -) (IDENTIFIER|PROPERTY) ]+ :
2074
+ if (isIdentOrProp(this.tokens[j])) {
2075
+ for (let k = j + 1; k < this.tokens.length; k += 2) {
2076
+ if (!isCompoundSep(this.tokens[k], this.tokens[k + 1])) break;
2077
+ if (!isIdentOrProp(this.tokens[k + 1])) break;
2078
+ if (this.tokens[k + 2]?.[0] === ':') return true;
2079
+ }
1951
2080
  }
1952
2081
  if (EXPRESSION_START.has(this.tokens[j]?.[0])) {
1953
2082
  let end = null;