@ball-lang/encoder 1.59.10 → 1.59.12

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/encoder.ts CHANGED
@@ -8,11 +8,43 @@ import type {
8
8
  export interface EncodeOptions {
9
9
  moduleName?: string;
10
10
  entryFunction?: string;
11
+ /** Throw an `EncodeError` on ANY unhandled construct, lossy or not. */
11
12
  strict?: boolean;
13
+ /**
14
+ * Throw an `EncodeError` only for warnings that change what the program
15
+ * computes, while tolerating erasure-only ones (a type alias, an empty
16
+ * statement, a `satisfies` clause — none of which contribute to the
17
+ * program's value).
18
+ *
19
+ * `strict` is the blunt instrument: it rejects a file for a stray `;`.
20
+ * This mode is the one worth running over third-party code, because every
21
+ * warning it raises is a real semantic difference between the source and
22
+ * the encoded Ball program.
23
+ */
24
+ strictBehaviorAffecting?: boolean;
12
25
  }
13
26
 
14
27
  type StdRef = { module: string; function: string };
15
28
 
29
+ /**
30
+ * TS constructs whose only role is type-level or syntactic: dropping them
31
+ * cannot change what the program computes, so they never trip
32
+ * `strictBehaviorAffecting`. Everything else that reaches `warn()` does.
33
+ *
34
+ * Only kinds that actually REACH `warn()` belong here. A type alias or an
35
+ * interface at top level is encoded (`typeAliases[]` / `typeDefs[]`) and never
36
+ * warns; nested inside a function body it has no statement branch, so it hits
37
+ * the "Unhandled statement kind" warn — which is the case this set exempts.
38
+ * `SatisfiesExpression` is deliberately absent: `x satisfies T` is erased in
39
+ * `encodeExpr` exactly like an `as` cast, silently and without a warning, so an
40
+ * entry for it would be dead.
41
+ */
42
+ const ERASURE_ONLY_KINDS: ReadonlySet<string> = new Set([
43
+ "TypeAliasDeclaration",
44
+ "InterfaceDeclaration",
45
+ "EmptyStatement",
46
+ ]);
47
+
16
48
  const BINARY_OPS: Record<number, StdRef> = {
17
49
  [ts.SyntaxKind.PlusToken]: { module: "std", function: "add" },
18
50
  [ts.SyntaxKind.MinusToken]: { module: "std", function: "subtract" },
@@ -36,6 +68,10 @@ const BINARY_OPS: Record<number, StdRef> = {
36
68
  [ts.SyntaxKind.BarBarToken]: { module: "std", function: "or" },
37
69
  [ts.SyntaxKind.QuestionQuestionToken]: { module: "std", function: "null_coalesce" },
38
70
  [ts.SyntaxKind.InstanceOfKeyword]: { module: "std", function: "is" },
71
+ // `**` used to fall through to the "Unhandled binary operator" placeholder
72
+ // (#490). math_pow is a declared, Dart-engine-implemented std base function
73
+ // that simply had no encoder emitting it.
74
+ [ts.SyntaxKind.AsteriskAsteriskToken]: { module: "std", function: "math_pow" },
39
75
  };
40
76
 
41
77
  const COMPOUND_OPS: Record<number, string> = {
@@ -50,12 +86,16 @@ const COMPOUND_OPS: Record<number, string> = {
50
86
  [ts.SyntaxKind.LessThanLessThanEqualsToken]: "<<=",
51
87
  [ts.SyntaxKind.GreaterThanGreaterThanEqualsToken]: ">>=",
52
88
  [ts.SyntaxKind.QuestionQuestionEqualsToken]: "??=",
89
+ [ts.SyntaxKind.AsteriskAsteriskEqualsToken]: "**=",
53
90
  };
54
91
 
55
92
  export class TsEncoder {
56
93
  private stdFunctions = new Set<string>();
57
94
  private warnings: string[] = [];
58
95
  private strict = false;
96
+ private strictBehaviorAffecting = false;
97
+ /** Monotonic counter for the synthetic loop variables `.forEach` desugars to. */
98
+ private desugarCounter = 0;
59
99
 
60
100
  // Maps an operator lexeme to the canonical, language-agnostic Ball method
61
101
  // name every compiler already understands for operator overloads — mirrors
@@ -90,6 +130,7 @@ export class TsEncoder {
90
130
  const modName = options.moduleName ?? "main";
91
131
  const entryFn = options.entryFunction ?? "main";
92
132
  this.strict = options.strict ?? false;
133
+ this.strictBehaviorAffecting = options.strictBehaviorAffecting ?? false;
93
134
 
94
135
  const sourceFile = ts.createSourceFile(
95
136
  "input.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS
@@ -353,7 +394,7 @@ export class TsEncoder {
353
394
  },
354
395
  }];
355
396
  }
356
- this.warn(`Unhandled statement kind: ${ts.SyntaxKind[node.kind]}`);
397
+ this.warn(`Unhandled statement kind: ${ts.SyntaxKind[node.kind]}`, ts.SyntaxKind[node.kind]);
357
398
  return [{ expression: { literal: { stringValue: `/* unhandled: ${ts.SyntaxKind[node.kind]} */` } } }];
358
399
  }
359
400
 
@@ -377,6 +418,14 @@ export class TsEncoder {
377
418
  if (node.kind === ts.SyntaxKind.NullKeyword || node.kind === ts.SyntaxKind.UndefinedKeyword) {
378
419
  return this.nullLiteral();
379
420
  }
421
+ if (node.kind === ts.SyntaxKind.SuperKeyword) {
422
+ // Mirrors dart/encoder's SuperExpression handling exactly: a bare
423
+ // reference named "super". ts/compiler already resolves that reference
424
+ // back to a real super-chain call, and conformance fixture
425
+ // 107_method_override_super proves the shape end to end — the TS encoder
426
+ // was simply the one place that never produced it (#490).
427
+ return { reference: { name: "super" } };
428
+ }
380
429
  if (node.kind === ts.SyntaxKind.ThisKeyword) {
381
430
  // Mirrors dart/encoder's ThisExpression handling exactly: a bare
382
431
  // reference named "self" (not a field-access base, not a special
@@ -409,8 +458,12 @@ export class TsEncoder {
409
458
  }
410
459
  if (ts.isPropertyAccessExpression(node)) {
411
460
  if (node.questionDotToken) {
412
- return this.stdCall("optional_access", [
413
- { name: "object", value: this.encodeExpr(node.expression) },
461
+ // Canonical name + field shape: compileStdCall's `case
462
+ // "null_aware_access"` reads `target` and a literal-string `field`
463
+ // (and so does dart/compiler). The old "optional_access" spelling with
464
+ // an `object` field existed in no compiler or engine (#489).
465
+ return this.stdCall("null_aware_access", [
466
+ { name: "target", value: this.encodeExpr(node.expression) },
414
467
  { name: "field", value: { literal: { stringValue: node.name.text } } },
415
468
  ]);
416
469
  }
@@ -418,9 +471,13 @@ export class TsEncoder {
418
471
  }
419
472
  if (ts.isElementAccessExpression(node)) {
420
473
  if (node.questionDotToken) {
421
- return this.stdCall("optional_access", [
422
- { name: "object", value: this.encodeExpr(node.expression) },
423
- { name: "field", value: this.encodeExpr(node.argumentExpression) },
474
+ // Optional ELEMENT access is a different shape: the index is an
475
+ // arbitrary expression, not a literal field name, so it routes through
476
+ // the compiler's dedicated `null_aware_index` case rather than being
477
+ // conflated with property access (#489).
478
+ return this.stdCall("null_aware_index", [
479
+ { name: "target", value: this.encodeExpr(node.expression) },
480
+ { name: "index", value: this.encodeExpr(node.argumentExpression) },
424
481
  ]);
425
482
  }
426
483
  return this.stdCall("index", [
@@ -518,10 +575,66 @@ export class TsEncoder {
518
575
  if (ts.isTaggedTemplateExpression(node)) {
519
576
  return this.encodeTaggedTemplate(node);
520
577
  }
521
- this.warn(`Unhandled expression kind: ${ts.SyntaxKind[node.kind]}`);
578
+ if (ts.isSatisfiesExpression(node)) {
579
+ // `x satisfies T` is a pure type-level assertion — erase it, exactly as
580
+ // `as`/`<T>` casts are erased above.
581
+ return this.encodeExpr(node.expression);
582
+ }
583
+ if (ts.isRegularExpressionLiteral(node)) {
584
+ return this.encodeRegexLiteral(node);
585
+ }
586
+ if (ts.isDeleteExpression(node)) {
587
+ return this.encodeDelete(node);
588
+ }
589
+ this.warn(`Unhandled expression kind: ${ts.SyntaxKind[node.kind]}`, ts.SyntaxKind[node.kind]);
522
590
  return { literal: { stringValue: `/* unhandled: ${ts.SyntaxKind[node.kind]} */` } };
523
591
  }
524
592
 
593
+ /**
594
+ * `delete obj.a` / `delete obj[k]` -> `std_collections.map_delete(map, key)`.
595
+ *
596
+ * `map_delete` has been declared and Dart-engine-implemented all along
597
+ * (dart/shared/lib/std_collections.dart) — no encoder in the repo had ever
598
+ * emitted it, so `delete` fell through to a placeholder literal (#490).
599
+ */
600
+ private encodeDelete(node: ts.DeleteExpression): Expression {
601
+ const operand = node.expression;
602
+ if (ts.isPropertyAccessExpression(operand)) {
603
+ return this.stdCall("map_delete", [
604
+ { name: "map", value: this.encodeExpr(operand.expression) },
605
+ { name: "key", value: { literal: { stringValue: operand.name.text } } },
606
+ ], "std_collections");
607
+ }
608
+ if (ts.isElementAccessExpression(operand)) {
609
+ return this.stdCall("map_delete", [
610
+ { name: "map", value: this.encodeExpr(operand.expression) },
611
+ { name: "key", value: this.encodeExpr(operand.argumentExpression) },
612
+ ], "std_collections");
613
+ }
614
+ // `delete someIdentifier` is a no-op on a binding in sloppy mode and a
615
+ // SyntaxError in strict mode — there is nothing faithful to encode.
616
+ this.warn(
617
+ `Unhandled delete target: ${ts.SyntaxKind[operand.kind]}`,
618
+ ts.SyntaxKind[operand.kind],
619
+ );
620
+ return { literal: { stringValue: `/* unhandled delete: ${ts.SyntaxKind[operand.kind]} */` } };
621
+ }
622
+
623
+ /**
624
+ * A regex literal encodes to its PATTERN SOURCE as a plain string.
625
+ *
626
+ * The universal std regex functions (`regex_match`/`regex_find`/
627
+ * `regex_find_all`/`regex_replace`/`regex_replace_all`) all take the pattern
628
+ * as a string and model no flags at all, so `/abc/i` cannot round-trip
629
+ * faithfully. Dropping `i`/`g`/`m` genuinely changes behaviour, so it is
630
+ * reported as a behaviour-affecting warning rather than silently accepted.
631
+ */
632
+ private encodeRegexLiteral(node: ts.RegularExpressionLiteral): Expression {
633
+ const re = splitRegexLiteral(node.text);
634
+ this.warnDroppedRegexFlags(re, "");
635
+ return { literal: { stringValue: re.source } };
636
+ }
637
+
525
638
  private encodeBinary(node: ts.BinaryExpression): Expression {
526
639
  const op = node.operatorToken.kind;
527
640
 
@@ -557,13 +670,15 @@ export class TsEncoder {
557
670
  }
558
671
 
559
672
  if (op === ts.SyntaxKind.InKeyword) {
560
- return this.stdCall("contains_key", [
673
+ // Canonical name (#489): `contains_key` matched no compiler case; the
674
+ // field shape (map/key) was already right.
675
+ return this.stdCall("map_contains_key", [
561
676
  { name: "map", value: this.encodeExpr(node.right) },
562
677
  { name: "key", value: this.encodeExpr(node.left) },
563
678
  ], "std_collections");
564
679
  }
565
680
 
566
- this.warn(`Unhandled binary operator: ${ts.SyntaxKind[op]}`);
681
+ this.warn(`Unhandled binary operator: ${ts.SyntaxKind[op]}`, ts.SyntaxKind[op]);
567
682
  return { literal: { stringValue: `/* binary: ${ts.SyntaxKind[op]} */` } };
568
683
  }
569
684
 
@@ -625,8 +740,18 @@ export class TsEncoder {
625
740
  { name: "value", value: this.encodeExpr(node.operand) },
626
741
  ]);
627
742
  }
628
- this.warn(`Unhandled prefix operator: ${ts.SyntaxKind[op]}`);
629
- return this.encodeExpr(node.operand);
743
+ // Fail loud (#490). Unary `+` is a numeric COERCION: `+"5"` is the number
744
+ // 5, not the string "5". Ball has no universal coerce-to-number base
745
+ // function (only string_to_int/string_to_double, which are wrong for a
746
+ // non-string operand), so there is nothing faithful to route to. This used
747
+ // to be the one unhandled path that emitted no placeholder at all — it
748
+ // returned the untouched operand, silently changing the value's type. Now
749
+ // it warns as behaviour-affecting and emits a placeholder like every other
750
+ // unhandled construct, so `strict`/`strictBehaviorAffecting` reject it and
751
+ // a lenient encode is at least visibly wrong instead of invisibly wrong.
752
+ // See ts/encoder/ENCODER_CARVEOUTS.md.
753
+ this.warn(`Unhandled prefix operator: ${ts.SyntaxKind[op]}`, ts.SyntaxKind[op]);
754
+ return { literal: { stringValue: `/* unary: ${ts.SyntaxKind[op]} */` } };
630
755
  }
631
756
 
632
757
  private encodePostfixUnary(node: ts.PostfixUnaryExpression): Expression {
@@ -641,6 +766,12 @@ export class TsEncoder {
641
766
  }
642
767
 
643
768
  private encodeCall(node: ts.CallExpression): Expression {
769
+ // Regex routing runs BEFORE the arguments are encoded: a regex literal in
770
+ // argument position must become a `from`/`right` pattern string, not a
771
+ // standalone (and separately flag-warned) expression.
772
+ const asRegex = this.tryEncodeRegexCall(node);
773
+ if (asRegex) return asRegex;
774
+
644
775
  const args = node.arguments.map((a, i) => ({
645
776
  name: `arg${i}`,
646
777
  value: this.encodeExpr(a),
@@ -689,6 +820,32 @@ export class TsEncoder {
689
820
 
690
821
  const obj = this.encodeExpr(node.expression.expression);
691
822
 
823
+ // `.forEach(cb)` has no runtime std equivalent — there is no
824
+ // list_for_each / list_foreach in dart/shared/lib/std_collections.dart at
825
+ // all, and the Dart encoder never routes iteration through a std call.
826
+ // Pointing at the TS compiler's (dead) `list_foreach` case would create a
827
+ // construct with no Dart-side meaning, so it is desugared to the native
828
+ // for_each control-flow node instead (#489).
829
+ if (method === "forEach" && node.arguments.length === 1) {
830
+ return this.desugarForEach(obj, node.arguments[0]);
831
+ }
832
+ // `.flat()` -> flatMap(identity). `list_flat_map` is the canonical
833
+ // std_collections function; `list_flatten` existed nowhere.
834
+ if (method === "flat" && node.arguments.length === 0) {
835
+ return this.stdCall("list_flat_map", [
836
+ { name: "list", value: obj },
837
+ { name: "function", value: identityLambda("__ball_flat_item") },
838
+ ], "std_collections");
839
+ }
840
+ // `.splice(i, n)` only maps cleanly to list_remove_at when n is exactly 1.
841
+ if (method === "splice" && !isSingleElementSplice(node)) {
842
+ this.warn(
843
+ `Array.splice is only representable as std_collections.list_remove_at ` +
844
+ `for the splice(index, 1) form`,
845
+ "ArraySplice",
846
+ );
847
+ }
848
+
692
849
  // Map common JS/TS method calls to their Ball std equivalents.
693
850
  // The engine's std module uses snake_case names with type prefixes.
694
851
  const stdMethod = this.mapMethodToStd(method, args);
@@ -699,10 +856,12 @@ export class TsEncoder {
699
856
  ], stdMethod.module ?? "std");
700
857
  }
701
858
 
702
- // Optional chaining call: obj?.method()
859
+ // Optional chaining call: obj?.method(). Canonical name + field shape
860
+ // (target/method), matching compileStdCall's `case "null_aware_call"`;
861
+ // "optional_call" with an `object` field matched no case at all (#489).
703
862
  if (node.expression.questionDotToken || node.questionDotToken) {
704
- return this.stdCall("optional_call", [
705
- { name: "object", value: obj },
863
+ return this.stdCall("null_aware_call", [
864
+ { name: "target", value: obj },
706
865
  { name: "method", value: { literal: { stringValue: method } } },
707
866
  ...args,
708
867
  ]);
@@ -758,6 +917,145 @@ export class TsEncoder {
758
917
  };
759
918
  }
760
919
 
920
+ /**
921
+ * Lower `xs.forEach(cb)` to the native `std.for_each` control-flow node.
922
+ *
923
+ * Ball has no `list_for_each` base function in any module — iteration is
924
+ * control flow, not a runtime call (Core Invariant #4), which is exactly how
925
+ * the Dart encoder treats it. When `cb` is an inline single-parameter
926
+ * function its parameter becomes the loop variable and its body becomes the
927
+ * loop body, so the emitted IR is indistinguishable from the equivalent
928
+ * `for (const x of xs)`. Otherwise the callback is invoked per element
929
+ * through `std.invoke`.
930
+ */
931
+ private desugarForEach(iterable: Expression, callback: ts.Expression): Expression {
932
+ const isInline = ts.isArrowFunction(callback) || ts.isFunctionExpression(callback);
933
+ if (isInline) {
934
+ const fn = callback as ts.ArrowFunction | ts.FunctionExpression;
935
+ if (fn.parameters.length > 1) {
936
+ // JS hands the callback (value, index, array); for_each yields only the
937
+ // element, so the extra parameters would silently be undefined.
938
+ this.warn(
939
+ `Array.forEach callback takes ${fn.parameters.length} parameters; ` +
940
+ `std.for_each yields only the element`,
941
+ "ForEachExtraParams",
942
+ );
943
+ }
944
+ const param = fn.parameters[0];
945
+ if (fn.parameters.length <= 1 && (param === undefined || ts.isIdentifier(param.name))) {
946
+ const varName = param && ts.isIdentifier(param.name)
947
+ ? param.name.text
948
+ : `__ball_foreach_${this.desugarCounter++}`;
949
+ return this.stdCall("for_each", [
950
+ { name: "variable", value: { literal: { stringValue: varName } } },
951
+ { name: "iterable", value: iterable },
952
+ { name: "body", value: this.encodeBody(fn.body) },
953
+ ]);
954
+ }
955
+ }
956
+ // A callback reference (or a destructuring parameter): bind a synthetic
957
+ // loop variable and invoke the callback with it.
958
+ const varName = `__ball_foreach_${this.desugarCounter++}`;
959
+ return this.stdCall("for_each", [
960
+ { name: "variable", value: { literal: { stringValue: varName } } },
961
+ { name: "iterable", value: iterable },
962
+ { name: "body", value: this.stdCall("invoke", [
963
+ { name: "callee", value: this.encodeExpr(callback) },
964
+ { name: "arg0", value: { reference: { name: varName } } },
965
+ ]) },
966
+ ]);
967
+ }
968
+
969
+ /**
970
+ * Route the JS regex API onto the universal `std` regex base functions
971
+ * (#490). All five have been declared and Dart-engine-implemented since the
972
+ * std module was written; no encoder in the repo had ever emitted one, so
973
+ * `tests/conformance/STD_COVERAGE.md` marked them Encoder-emittable ❌.
974
+ *
975
+ * /re/.test(s) -> std.regex_match(left: s, right: "re")
976
+ * /re/.exec(s) -> std.regex_find(left: s, right: "re")
977
+ * s.match(/re/g) -> std.regex_find_all(left: s, right: "re")
978
+ * s.replace(/re/, x) -> std.regex_replace(value: s, from: "re", to: x)
979
+ * s.replace(/re/g, x) -> std.regex_replace_all(...) (`g` is honoured)
980
+ * s.replaceAll(/re/, x)-> std.regex_replace_all(...)
981
+ *
982
+ * Returns undefined when the call is not a regex shape, so the ordinary
983
+ * method-mapping path takes over.
984
+ */
985
+ private tryEncodeRegexCall(node: ts.CallExpression): Expression | undefined {
986
+ if (!ts.isPropertyAccessExpression(node.expression)) return undefined;
987
+ const method = node.expression.name.text;
988
+ const receiver = node.expression.expression;
989
+ const args = node.arguments;
990
+
991
+ // Every std name below is spelled as a LITERAL argument to stdCall (never
992
+ // computed from a ternary) so ts/compiler/test/std_name_consistency.test.ts
993
+ // can see, by static scan, exactly which functions this encoder can emit.
994
+ const receiverRe = regexLiteralOf(receiver);
995
+ if (receiverRe && (method === "test" || method === "exec") && args.length === 1) {
996
+ this.warnDroppedRegexFlags(receiverRe, "");
997
+ const fields: FieldValuePair[] = [
998
+ { name: "left", value: this.encodeExpr(args[0]) },
999
+ { name: "right", value: { literal: { stringValue: receiverRe.source } } },
1000
+ ];
1001
+ return method === "test"
1002
+ ? this.stdCall("regex_match", fields)
1003
+ : this.stdCall("regex_find", fields);
1004
+ }
1005
+
1006
+ const argRe = args.length > 0 ? regexLiteralOf(args[0]) : undefined;
1007
+ if (!argRe) return undefined;
1008
+
1009
+ if ((method === "replace" || method === "replaceAll") && args.length === 2) {
1010
+ const all = method === "replaceAll" || argRe.flags.includes("g");
1011
+ this.warnDroppedRegexFlags(argRe, "g");
1012
+ const fields: FieldValuePair[] = [
1013
+ { name: "value", value: this.encodeExpr(receiver) },
1014
+ { name: "from", value: { literal: { stringValue: argRe.source } } },
1015
+ { name: "to", value: this.encodeExpr(args[1]) },
1016
+ ];
1017
+ return all
1018
+ ? this.stdCall("regex_replace_all", fields)
1019
+ : this.stdCall("regex_replace", fields);
1020
+ }
1021
+
1022
+ if ((method === "match" || method === "matchAll") && args.length === 1) {
1023
+ // JS `.match(/re/)` (no `g`) returns a match ARRAY with capture groups
1024
+ // and an index; `regex_find` returns just the matched substring. Only the
1025
+ // global form maps cleanly, so the non-global one is reported as lossy
1026
+ // rather than quietly re-shaped.
1027
+ const global = method === "matchAll" || argRe.flags.includes("g");
1028
+ this.warnDroppedRegexFlags(argRe, "g");
1029
+ if (!global) {
1030
+ this.warn(
1031
+ `String.match without the "g" flag returns a match array with capture ` +
1032
+ `groups; std.regex_find returns only the matched substring`,
1033
+ "RegexMatchNonGlobal",
1034
+ );
1035
+ }
1036
+ const fields: FieldValuePair[] = [
1037
+ { name: "left", value: this.encodeExpr(receiver) },
1038
+ { name: "right", value: { literal: { stringValue: argRe.source } } },
1039
+ ];
1040
+ return global
1041
+ ? this.stdCall("regex_find_all", fields)
1042
+ : this.stdCall("regex_find", fields);
1043
+ }
1044
+
1045
+ return undefined;
1046
+ }
1047
+
1048
+ /** Warn about every regex flag that the chosen std routing does not honour. */
1049
+ private warnDroppedRegexFlags(re: { source: string; flags: string }, implied: string): void {
1050
+ const dropped = [...re.flags].filter((f) => !implied.includes(f)).join("");
1051
+ if (dropped.length === 0) return;
1052
+ this.warn(
1053
+ `Dropping regular-expression flags "${dropped}" from /${re.source}/${re.flags} — ` +
1054
+ `the std regex_* functions take a bare pattern string and model no flags`,
1055
+ "RegularExpressionLiteralFlags",
1056
+ );
1057
+ }
1058
+
761
1059
  private encodeLambda(node: ts.ArrowFunction | ts.FunctionExpression): Expression {
762
1060
  const params = node.parameters.map(p =>
763
1061
  ts.isIdentifier(p.name) ? p.name.text : p.name.getText()
@@ -869,14 +1167,19 @@ export class TsEncoder {
869
1167
  const d = node.initializer.declarations[0];
870
1168
  varName = ts.isIdentifier(d.name) ? d.name.text : d.name.getText();
871
1169
  }
872
- const fnName = ts.isForInStatement(node) ? "for_in" : "for_each";
873
- return this.stdCall(fnName, [
1170
+ const fields: FieldValuePair[] = [
874
1171
  { name: "variable", value: { literal: { stringValue: varName } } },
875
1172
  { name: "iterable", value: this.encodeExpr(node.expression) },
876
1173
  { name: "body", value: this.encodeBody(
877
1174
  ts.isBlock(node.statement) ? node.statement : ts.factory.createBlock([node.statement])
878
1175
  ) },
879
- ]);
1176
+ ];
1177
+ // Both names are spelled as literals rather than computed from a ternary so
1178
+ // that a static scan of this file (ts/compiler/test/std_name_consistency.
1179
+ // test.ts) can see every std function the encoder is able to emit.
1180
+ return ts.isForInStatement(node)
1181
+ ? this.stdCall("for_in", fields)
1182
+ : this.stdCall("for_each", fields);
880
1183
  }
881
1184
 
882
1185
  private encodeWhile(node: ts.WhileStatement): Expression {
@@ -1047,7 +1350,10 @@ export class TsEncoder {
1047
1350
  // operator) has no Ball representation yet — warn (and throw in
1048
1351
  // strict mode) rather than silently dropping it from the encoded
1049
1352
  // class (#242).
1050
- this.warn(`Unhandled class member name: ${member.name.getText()}`);
1353
+ this.warn(
1354
+ `Unhandled class member name: ${member.name.getText()}`,
1355
+ ts.SyntaxKind[member.name.kind],
1356
+ );
1051
1357
  }
1052
1358
  if (fn) {
1053
1359
  fn.name = `${className}.${fn.name}`;
@@ -1146,32 +1452,45 @@ export class TsEncoder {
1146
1452
  /**
1147
1453
  * Map a JS/TS method name to its Ball std function equivalent.
1148
1454
  * Returns null if no mapping exists (falls through to generic method call).
1455
+ *
1456
+ * Every entry names BOTH the canonical std function AND the field names that
1457
+ * function's arguments carry. Names alone are not enough: `compileStdCall`
1458
+ * looks arguments up by field name, so `string_replace` fed `other`/`arg1`
1459
+ * (the old positional scheme) silently compiled to `''`, and
1460
+ * `string_substring` fed the same way crashed. The field names below were
1461
+ * each read off the corresponding `case` body in ts/compiler/src/compiler.ts
1462
+ * (#489).
1149
1463
  */
1150
1464
  private mapMethodToStd(
1151
1465
  method: string,
1152
1466
  _args: { name: string; value: Expression }[],
1153
1467
  ): { fn: string; module?: string; selfName?: string; extraFields: (a: typeof _args) => FieldValuePair[] } | null {
1154
- // String methods
1155
- const STR_METHODS: Record<string, string> = {
1156
- toUpperCase: "string_to_upper_case",
1157
- toLowerCase: "string_to_lower_case",
1158
- trim: "string_trim",
1159
- trimStart: "string_trim_left",
1160
- trimEnd: "string_trim_right",
1161
- includes: "string_contains",
1162
- indexOf: "string_index_of",
1163
- startsWith: "string_starts_with",
1164
- endsWith: "string_ends_with",
1165
- split: "string_split",
1166
- substring: "string_substring",
1167
- slice: "string_substring",
1168
- replace: "string_replace_first",
1169
- replaceAll: "string_replace_all",
1170
- padStart: "string_pad_left",
1171
- padEnd: "string_pad_right",
1172
- repeat: "string_repeat",
1173
- charAt: "string_char_at",
1174
- charCodeAt: "string_code_unit_at",
1468
+ /** Name the i-th argument after `names[i]`, falling back to `argN`. */
1469
+ const named = (...names: string[]) =>
1470
+ (a: typeof _args): FieldValuePair[] =>
1471
+ a.map((x, i) => ({ name: names[i] ?? `arg${i}`, value: x.value }));
1472
+
1473
+ // String methods: `value` is the receiver.
1474
+ const STR_METHODS: Record<string, { fn: string; args: string[] }> = {
1475
+ toUpperCase: { fn: "string_to_upper", args: [] },
1476
+ toLowerCase: { fn: "string_to_lower", args: [] },
1477
+ trim: { fn: "string_trim", args: [] },
1478
+ trimStart: { fn: "string_trim_start", args: [] },
1479
+ trimEnd: { fn: "string_trim_end", args: [] },
1480
+ includes: { fn: "string_contains", args: ["other"] },
1481
+ indexOf: { fn: "string_index_of", args: ["pattern", "start"] },
1482
+ startsWith: { fn: "string_starts_with", args: ["other"] },
1483
+ endsWith: { fn: "string_ends_with", args: ["other"] },
1484
+ split: { fn: "string_split", args: ["separator"] },
1485
+ substring: { fn: "string_substring", args: ["start", "end"] },
1486
+ slice: { fn: "string_substring", args: ["start", "end"] },
1487
+ replace: { fn: "string_replace", args: ["from", "to"] },
1488
+ replaceAll: { fn: "string_replace_all", args: ["from", "to"] },
1489
+ padStart: { fn: "string_pad_left", args: ["width", "padding"] },
1490
+ padEnd: { fn: "string_pad_right", args: ["width", "padding"] },
1491
+ repeat: { fn: "string_repeat", args: ["count"] },
1492
+ charAt: { fn: "string_char_at", args: ["index"] },
1493
+ charCodeAt: { fn: "string_code_unit_at", args: ["index"] },
1175
1494
  };
1176
1495
  // hasOwnProperty (not `in`) — `in` also matches names inherited from
1177
1496
  // Object.prototype (toString, valueOf, hasOwnProperty, ...). A bare `in`
@@ -1182,48 +1501,38 @@ export class TsEncoder {
1182
1501
  // corrupt call.function (a function object, not "to_string") in the Ball
1183
1502
  // IR. Found via coverage analysis of the always-dead toString branch.
1184
1503
  if (Object.prototype.hasOwnProperty.call(STR_METHODS, method)) {
1185
- return {
1186
- fn: STR_METHODS[method],
1187
- selfName: "value",
1188
- extraFields: (a) => a.map((x, i) => ({
1189
- name: i === 0 ? "other" : `arg${i}`,
1190
- value: x.value,
1191
- })),
1192
- };
1193
- }
1194
-
1195
- // Array methods
1196
- const ARR_METHODS: Record<string, { fn: string; mod?: string }> = {
1197
- push: { fn: "list_add" },
1198
- pop: { fn: "list_remove_last" },
1199
- indexOf: { fn: "list_index_of", mod: "std_collections" },
1200
- includes: { fn: "list_contains", mod: "std_collections" },
1201
- join: { fn: "list_join", mod: "std_collections" },
1202
- reverse: { fn: "list_reversed", mod: "std_collections" },
1203
- slice: { fn: "list_sublist", mod: "std_collections" },
1204
- splice: { fn: "list_remove_at" },
1205
- sort: { fn: "list_sort", mod: "std_collections" },
1206
- map: { fn: "list_map", mod: "std_collections" },
1207
- filter: { fn: "list_where", mod: "std_collections" },
1208
- forEach: { fn: "list_for_each", mod: "std_collections" },
1209
- reduce: { fn: "list_fold", mod: "std_collections" },
1210
- find: { fn: "list_first_where", mod: "std_collections" },
1211
- flat: { fn: "list_flatten", mod: "std_collections" },
1212
- concat: { fn: "list_concat", mod: "std_collections" },
1213
- every: { fn: "list_every", mod: "std_collections" },
1214
- some: { fn: "list_any", mod: "std_collections" },
1504
+ const m = STR_METHODS[method];
1505
+ return { fn: m.fn, selfName: "value", extraFields: named(...m.args) };
1506
+ }
1507
+
1508
+ // Array methods: `list` is the receiver. All of them live in
1509
+ // std_collections — the module the Dart reference declares them in.
1510
+ const ARR_METHODS: Record<string, { fn: string; args: string[] }> = {
1511
+ push: { fn: "list_push", args: ["value"] },
1512
+ pop: { fn: "list_pop", args: [] },
1513
+ indexOf: { fn: "list_index_of", args: ["value"] },
1514
+ includes: { fn: "list_contains", args: ["value"] },
1515
+ join: { fn: "list_join", args: ["separator"] },
1516
+ reverse: { fn: "list_reverse", args: [] },
1517
+ slice: { fn: "list_slice", args: ["start", "end"] },
1518
+ splice: { fn: "list_remove_at", args: ["index"] },
1519
+ sort: { fn: "list_sort", args: ["comparator"] },
1520
+ map: { fn: "list_map", args: ["function"] },
1521
+ filter: { fn: "list_filter", args: ["function"] },
1522
+ reduce: { fn: "list_reduce", args: ["function"] },
1523
+ find: { fn: "list_find", args: ["function"] },
1524
+ concat: { fn: "list_concat", args: ["other"] },
1525
+ every: { fn: "list_all", args: ["function"] },
1526
+ some: { fn: "list_any", args: ["function"] },
1215
1527
  };
1216
1528
  // Same hasOwnProperty rationale as STR_METHODS above.
1217
1529
  if (Object.prototype.hasOwnProperty.call(ARR_METHODS, method)) {
1218
1530
  const m = ARR_METHODS[method];
1219
1531
  return {
1220
1532
  fn: m.fn,
1221
- module: m.mod,
1533
+ module: "std_collections",
1222
1534
  selfName: "list",
1223
- extraFields: (a) => a.map((x, i) => ({
1224
- name: i === 0 ? "value" : `arg${i}`,
1225
- value: x.value,
1226
- })),
1535
+ extraFields: named(...m.args),
1227
1536
  };
1228
1537
  }
1229
1538
 
@@ -1284,7 +1593,16 @@ export class TsEncoder {
1284
1593
  return { literal: {} };
1285
1594
  }
1286
1595
 
1287
- private warn(msg: string): void {
1596
+ /**
1597
+ * Record a warning, and turn it into a hard error when the caller asked for
1598
+ * one.
1599
+ *
1600
+ * `kind` is the TS SyntaxKind name (or another marker) the warning is about;
1601
+ * it is what `strictBehaviorAffecting` filters on, so the distinction between
1602
+ * "erased a type alias" and "silently changed what this program computes" is
1603
+ * mechanical rather than a substring match on the message.
1604
+ */
1605
+ private warn(msg: string, kind?: string): void {
1288
1606
  this.warnings.push(msg);
1289
1607
  // In strict mode an unhandled node is a hard error: the encoder cannot
1290
1608
  // faithfully represent the construct and would otherwise emit a
@@ -1292,6 +1610,9 @@ export class TsEncoder {
1292
1610
  if (this.strict) {
1293
1611
  throw new EncodeError(msg, this.getWarnings());
1294
1612
  }
1613
+ if (this.strictBehaviorAffecting && !(kind !== undefined && ERASURE_ONLY_KINDS.has(kind))) {
1614
+ throw new EncodeError(msg, this.getWarnings());
1615
+ }
1295
1616
  }
1296
1617
 
1297
1618
  getWarnings(): string[] {
@@ -1299,6 +1620,43 @@ export class TsEncoder {
1299
1620
  }
1300
1621
  }
1301
1622
 
1623
+ /**
1624
+ * Split a regex literal's raw text (`/source/flags`) into its two parts.
1625
+ * The closing delimiter is the LAST unescaped `/`, so a pattern containing an
1626
+ * escaped slash (`/a\/b/g`) splits correctly.
1627
+ */
1628
+ function splitRegexLiteral(text: string): { source: string; flags: string } {
1629
+ const lastSlash = text.lastIndexOf("/");
1630
+ return {
1631
+ source: text.slice(1, lastSlash),
1632
+ flags: text.slice(lastSlash + 1),
1633
+ };
1634
+ }
1635
+
1636
+ /** The `{ source, flags }` of `node` when it is a regex literal, else undefined. */
1637
+ function regexLiteralOf(node: ts.Node): { source: string; flags: string } | undefined {
1638
+ if (ts.isParenthesizedExpression(node)) return regexLiteralOf(node.expression);
1639
+ return ts.isRegularExpressionLiteral(node) ? splitRegexLiteral(node.text) : undefined;
1640
+ }
1641
+
1642
+ /** A `(x) => x` lambda, in the encoder's own lambda shape. */
1643
+ function identityLambda(param: string): Expression {
1644
+ return {
1645
+ lambda: {
1646
+ name: "",
1647
+ body: { reference: { name: param } },
1648
+ metadata: { params: [{ name: param }] },
1649
+ },
1650
+ };
1651
+ }
1652
+
1653
+ /** True for the `xs.splice(i, 1)` form, the only one list_remove_at models. */
1654
+ function isSingleElementSplice(node: ts.CallExpression): boolean {
1655
+ if (node.arguments.length !== 2) return false;
1656
+ const count = node.arguments[1];
1657
+ return ts.isNumericLiteral(count) && count.text === "1";
1658
+ }
1659
+
1302
1660
  /// Thrown by `encode(..., { strict: true })` when the encoder hits a TS
1303
1661
  /// construct it cannot represent. Carries the full accumulated warning list.
1304
1662
  export class EncodeError extends Error {