@pineforge/codegen-pyodide 0.10.0 → 0.10.2

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.
@@ -40,16 +40,35 @@ from __future__ import annotations
40
40
  from typing import Any
41
41
 
42
42
  from ..ast_nodes import (
43
- ASTNode, BoolLiteral, FuncCall, Identifier, MemberAccess,
44
- NaLiteral, NumberLiteral, StringLiteral, UnaryOp,
43
+ ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, IfStmt,
44
+ MemberAccess, NaLiteral, NumberLiteral, StringLiteral, Subscript, Ternary,
45
+ SwitchStmt, TupleLiteral, UnaryOp,
45
46
  )
46
- from ..symbols import PineType, TypeSpec
47
+ from ..symbols import PineType, TypeSpec, method_receiver_type_name
47
48
 
48
49
  # Drawing-objects-as-data type names (spec §4.1). Defined locally — the
49
50
  # analyzer must not import from ``codegen`` (codegen imports analyzer, so the
50
51
  # reverse would be a cycle). Mirrors codegen.tables.DRAWING_TYPE_TO_CPP keys.
51
52
  _DRAWING_TYPE_NAMES = frozenset({"line", "box", "label", "linefill", "chart.point"})
52
53
  _DRAWING_NS = frozenset({"line", "box", "label", "linefill"})
54
+ _DIRECT_ARRAY_VALUE_PRODUCERS = frozenset({
55
+ "from",
56
+ "new",
57
+ "new_float",
58
+ "new_int",
59
+ "new_bool",
60
+ "new_string",
61
+ "copy",
62
+ })
63
+
64
+ # Keep this analyzer-owned mirror in sync with
65
+ # codegen.tables.MATRIX_RETURNING_METHODS. The analyzer cannot import from
66
+ # codegen (codegen already imports analyzer), but nullable selections need the
67
+ # exact matrix result type before codegen registers global aggregate members.
68
+ _MATRIX_RETURNING_METHODS = frozenset({
69
+ "copy", "submatrix", "transpose", "concat", "diff", "mult", "pow",
70
+ "inv", "pinv", "eigenvectors", "kron",
71
+ })
53
72
 
54
73
 
55
74
  class TypeHelper:
@@ -134,14 +153,292 @@ class TypeHelper:
134
153
  raw = ann.get("template_args") or []
135
154
  return [str(x).replace(" ", "") for x in raw]
136
155
 
156
+ def _array_from_element_spec(self, value: ASTNode | None) -> TypeSpec | None:
157
+ """Exact scalar element type for one ``array.from`` argument.
158
+
159
+ Keep this refinement local to array construction. General BinOp
160
+ TypeSpec inference changes unrelated scalar comparator lowering across
161
+ the corpus; array declarations only need enough structure to keep the
162
+ analyzer's captured LHS type aligned with codegen's RHS vector type.
163
+ """
164
+ if value is None:
165
+ return None
166
+ if isinstance(value, NumberLiteral):
167
+ return TypeSpec.primitive(
168
+ "float" if isinstance(value.value, float) else "int"
169
+ )
170
+ if isinstance(value, BoolLiteral):
171
+ return TypeSpec.primitive("bool")
172
+ if isinstance(value, StringLiteral):
173
+ return TypeSpec.primitive("string")
174
+ if isinstance(value, BinOp):
175
+ left = self._array_from_element_spec(value.left)
176
+ right = self._array_from_element_spec(value.right)
177
+ if value.op in ("==", "!=", ">", "<", ">=", "<=", "and", "or"):
178
+ return TypeSpec.primitive("bool")
179
+ if (left is not None and right is not None
180
+ and left.kind == "primitive" and right.kind == "primitive"):
181
+ if left.name == "string" or right.name == "string":
182
+ return TypeSpec.primitive("string")
183
+ if value.op == "/" or left.name == "float" or right.name == "float":
184
+ return TypeSpec.primitive("float")
185
+ if left.name == "int" and right.name == "int":
186
+ return TypeSpec.primitive("int")
187
+ return None
188
+ spec = self._type_spec_from_expr(value)
189
+ if spec is not None:
190
+ return spec
191
+ if isinstance(value, Identifier):
192
+ sym = self._symbols.resolve(value.name)
193
+ if sym is not None and sym.pine_type in {
194
+ PineType.INT, PineType.FLOAT, PineType.BOOL,
195
+ PineType.STRING, PineType.COLOR,
196
+ }:
197
+ return self._pine_type_to_spec(sym.pine_type)
198
+ return None
199
+
200
+ @staticmethod
201
+ def _selection_terminal_expr(
202
+ body: list[ASTNode] | None,
203
+ ) -> ASTNode | None:
204
+ """Return one if/switch branch's value expression, if present."""
205
+ if not body:
206
+ return None
207
+ terminal = body[-1]
208
+ return terminal.expr if isinstance(terminal, ExprStmt) else terminal
209
+
210
+ @staticmethod
211
+ def _selection_node_is_na(node: ASTNode | None) -> bool:
212
+ """Whether a selection arm is explicit or implicit Pine ``na``."""
213
+ return (
214
+ node is None
215
+ or isinstance(node, NaLiteral)
216
+ or (isinstance(node, Identifier) and node.name == "na")
217
+ )
218
+
219
+ def _nullable_collection_selection_spec(
220
+ self,
221
+ branches: list[tuple[ASTNode | None, TypeSpec | None]],
222
+ ) -> TypeSpec | None:
223
+ """Unify compatible map/matrix selection arms around typed ``na``.
224
+
225
+ A missing ``if``/``switch`` fallback is Pine's implicit ``na`` arm.
226
+ Every concrete arm must carry the same nullable collection TypeSpec;
227
+ an unknown or incompatible concrete arm fails closed.
228
+ """
229
+ concrete: list[TypeSpec] = []
230
+ for node, spec in branches:
231
+ if self._selection_node_is_na(node):
232
+ continue
233
+ if spec is None or spec.kind not in {"map", "matrix"}:
234
+ return None
235
+ concrete.append(spec)
236
+ if not concrete:
237
+ return None
238
+ first = concrete[0]
239
+ return first if all(spec == first for spec in concrete[1:]) else None
240
+
137
241
  def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None:
138
242
  if value is None:
139
243
  return None
244
+ if isinstance(value, NumberLiteral):
245
+ return TypeSpec.primitive(
246
+ "float" if isinstance(value.value, float) else "int"
247
+ )
248
+ if isinstance(value, BoolLiteral):
249
+ return TypeSpec.primitive("bool")
250
+ if isinstance(value, StringLiteral):
251
+ return TypeSpec.primitive("string")
252
+ if isinstance(value, Ternary):
253
+ true_spec = self._type_spec_from_expr(value.true_val)
254
+ false_spec = self._type_spec_from_expr(value.false_val)
255
+ collection_spec = self._nullable_collection_selection_spec([
256
+ (value.true_val, true_spec),
257
+ (value.false_val, false_spec),
258
+ ])
259
+ if collection_spec is not None:
260
+ return collection_spec
261
+
262
+ def direct_user_udt_ctor_name(node: ASTNode) -> str | None:
263
+ if not isinstance(node, FuncCall):
264
+ return None
265
+ callee = node.callee
266
+ if not (
267
+ isinstance(callee, MemberAccess)
268
+ and isinstance(callee.object, Identifier)
269
+ and callee.member == "new"
270
+ ):
271
+ return None
272
+ name = callee.object.name
273
+ return name if name in self._udt_fields else None
274
+
275
+ # Selecting between two values of the same user-defined type
276
+ # preserves that receiver type. Codegen already applies this
277
+ # rule; the analyzer must agree so stateful method calls on a UDT
278
+ # ternary enter the written-callsite clone graph.
279
+ if (true_spec is not None
280
+ and true_spec.kind == "udt"
281
+ and true_spec == false_spec):
282
+ return true_spec
283
+ # A direct user-UDT constructor selected against bare ``na`` has
284
+ # one unambiguous value type. Require the constructor AST itself,
285
+ # not merely an inferred UDT expression, so temporary array-element
286
+ # identity returns continue to fail closed on their own surface.
287
+ true_ctor = direct_user_udt_ctor_name(value.true_val)
288
+ if (true_spec is not None
289
+ and true_spec.kind == "udt"
290
+ and true_spec.name == true_ctor
291
+ and isinstance(value.false_val, NaLiteral)):
292
+ return true_spec
293
+ false_ctor = direct_user_udt_ctor_name(value.false_val)
294
+ if (false_spec is not None
295
+ and false_spec.kind == "udt"
296
+ and false_spec.name == false_ctor
297
+ and isinstance(value.true_val, NaLiteral)):
298
+ return false_spec
299
+ # Drawing handles are nullable reference-like values in Pine. A
300
+ # bare ``na`` arm therefore acquires the other arm's exact handle
301
+ # type, just like the established PineMap path above. Keep this
302
+ # intentionally narrower than arbitrary UDTs/collections: their
303
+ # target-typed selection semantics are not established here.
304
+ if (true_spec is not None
305
+ and true_spec.kind == "udt"
306
+ and true_spec.name in _DRAWING_TYPE_NAMES
307
+ and isinstance(value.false_val, NaLiteral)):
308
+ return true_spec
309
+ if (false_spec is not None
310
+ and false_spec.kind == "udt"
311
+ and false_spec.name in _DRAWING_TYPE_NAMES
312
+ and isinstance(value.true_val, NaLiteral)):
313
+ return false_spec
314
+ return None
315
+ if isinstance(value, Subscript):
316
+ # Pine's history operator preserves the value type: a
317
+ # ``Series<line>`` read such as ``h[1]`` is a scalar ``line``
318
+ # handle, not the legacy numeric fallback. Keep this refinement
319
+ # drawing-only; collection subscripts have separate array/map
320
+ # semantics and primitive history inference already flows through
321
+ # PineType in ``_visit_Subscript``.
322
+ receiver_spec = self._type_spec_from_expr(value.object)
323
+ if (receiver_spec is not None
324
+ and receiver_spec.kind == "udt"
325
+ and receiver_spec.name in _DRAWING_TYPE_NAMES):
326
+ return receiver_spec
327
+ return None
328
+ if isinstance(value, IfStmt):
329
+ true_node = self._selection_terminal_expr(value.body)
330
+ false_node = self._selection_terminal_expr(value.else_body)
331
+ true_spec = self._type_spec_from_expr(true_node)
332
+ false_spec = self._type_spec_from_expr(false_node)
333
+ collection_spec = self._nullable_collection_selection_spec([
334
+ (true_node, true_spec),
335
+ (false_node, false_spec),
336
+ ])
337
+ if collection_spec is not None:
338
+ return collection_spec
339
+ true_is_na = (
340
+ isinstance(true_node, NaLiteral)
341
+ or (isinstance(true_node, Identifier)
342
+ and true_node.name == "na")
343
+ )
344
+ false_is_na = (
345
+ isinstance(false_node, NaLiteral)
346
+ or (isinstance(false_node, Identifier)
347
+ and false_node.name == "na")
348
+ )
349
+ if (true_spec is not None
350
+ and true_spec.kind == "udt"
351
+ and true_spec.name in _DRAWING_TYPE_NAMES
352
+ and true_spec == false_spec):
353
+ return true_spec
354
+ if (true_spec is not None
355
+ and true_spec.kind == "udt"
356
+ and true_spec.name in _DRAWING_TYPE_NAMES
357
+ and false_is_na):
358
+ return true_spec
359
+ if (false_spec is not None
360
+ and false_spec.kind == "udt"
361
+ and false_spec.name in _DRAWING_TYPE_NAMES
362
+ and true_is_na):
363
+ return false_spec
364
+ return None
365
+ if isinstance(value, SwitchStmt):
366
+ branches: list[tuple[ASTNode | None, TypeSpec | None]] = []
367
+ for _case_expr, case_body in value.cases:
368
+ terminal = self._selection_terminal_expr(case_body)
369
+ branches.append((
370
+ terminal,
371
+ self._type_spec_from_expr(terminal),
372
+ ))
373
+ default_terminal = self._selection_terminal_expr(
374
+ value.default_body
375
+ )
376
+ branches.append((
377
+ default_terminal,
378
+ self._type_spec_from_expr(default_terminal),
379
+ ))
380
+ return self._nullable_collection_selection_spec(branches)
140
381
  if isinstance(value, FuncCall):
141
382
  cal = value.callee
142
383
  func = cal.member if isinstance(cal, MemberAccess) else None
143
384
  ns = cal.object.name if isinstance(cal, MemberAccess) and isinstance(cal.object, Identifier) else None
144
385
  targs = self._template_args_from_call(value)
386
+ if isinstance(cal, MemberAccess):
387
+ typed_receiver_spec = self._type_spec_from_expr(cal.object)
388
+ typed_receiver_name = method_receiver_type_name(
389
+ typed_receiver_spec
390
+ )
391
+ method_key = (
392
+ f"{typed_receiver_name}.{func}"
393
+ if typed_receiver_name is not None
394
+ else None
395
+ )
396
+ method_info = next(
397
+ (
398
+ info
399
+ for info in getattr(self, "_func_infos", ())
400
+ if info.name == method_key
401
+ and getattr(info, "is_udt_method", False)
402
+ ),
403
+ None,
404
+ ) if typed_receiver_name is not None else None
405
+ if method_info is not None:
406
+ return_spec = getattr(
407
+ method_info, "return_type_spec", None
408
+ )
409
+ if return_spec is not None:
410
+ return return_spec
411
+ udt_return = getattr(
412
+ method_info, "udt_return_type", None
413
+ )
414
+ if udt_return is not None:
415
+ return TypeSpec.udt(udt_return)
416
+ if method_info.return_type in {
417
+ PineType.INT,
418
+ PineType.FLOAT,
419
+ PineType.BOOL,
420
+ PineType.STRING,
421
+ PineType.COLOR,
422
+ }:
423
+ return self._pine_type_to_spec(
424
+ method_info.return_type
425
+ )
426
+ return None
427
+ if (
428
+ method_key is not None
429
+ and method_key in getattr(self, "_method_signatures", {})
430
+ ):
431
+ # A later authored method declaration owns this surface.
432
+ # Its body-derived return type is not available yet, but a
433
+ # same-named builtin must not lend it a false type.
434
+ return None
435
+ if (
436
+ typed_receiver_spec is not None
437
+ and typed_receiver_spec.kind == "udt"
438
+ and typed_receiver_spec.name in self._udt_fields
439
+ and func == "copy"
440
+ ):
441
+ return typed_receiver_spec
145
442
  # Drawing-objects-as-data return typing: *.new / *.copy -> handle of
146
443
  # the self-type; linefill.get_line* -> line; chart.point.* -> point.
147
444
  if ns in _DRAWING_NS:
@@ -166,16 +463,27 @@ class TypeHelper:
166
463
  elem = self._type_spec_from_hint(targs[0]) or TypeSpec.udt(targs[0])
167
464
  return TypeSpec.array(elem)
168
465
  if func == "from" and value.args:
466
+ first_spec = self._array_from_element_spec(value.args[0])
467
+ if first_spec is not None:
468
+ return TypeSpec.array(first_spec)
169
469
  first = self._visit(value.args[0])
170
470
  return TypeSpec.array(self._pine_type_to_spec(first))
171
471
  return TypeSpec.array(TypeSpec.primitive("float"))
172
472
  # Functional-form array element/copy accessors: the receiver is
173
- # the first argument (``array.copy(arr)``), mirroring the
174
- # method-form handling below (``arr.copy()``).
175
- if (ns == "array" and value.args
473
+ # the first argument (``array.copy(arr)``), or the exact ``id``
474
+ # keyword for ``array.copy(id=arr)``. The latter deliberately
475
+ # uses the shape validator shared by terminal-return recovery so
476
+ # duplicate/unknown keyword forms do not acquire a type by
477
+ # accident.
478
+ if (ns == "array"
176
479
  and func in ("copy", "slice", "get", "first", "last",
177
480
  "pop", "shift", "remove")):
178
- arg_spec = self._type_spec_from_expr(value.args[0])
481
+ receiver = None
482
+ if func == "copy":
483
+ receiver = self._direct_namespace_array_copy_source(value)
484
+ elif value.args:
485
+ receiver = value.args[0]
486
+ arg_spec = self._type_spec_from_expr(receiver)
179
487
  if arg_spec is not None and arg_spec.kind == "array":
180
488
  if func in ("copy", "slice"):
181
489
  return arg_spec
@@ -186,10 +494,37 @@ class TypeHelper:
186
494
  else:
187
495
  elem = TypeSpec.primitive("float")
188
496
  return TypeSpec.matrix(elem)
497
+ if ns == "matrix" and func in _MATRIX_RETURNING_METHODS:
498
+ receiver = value.args[0] if value.args else value.kwargs.get("id")
499
+ receiver_spec = self._type_spec_from_expr(receiver)
500
+ if receiver_spec is not None and receiver_spec.kind == "matrix":
501
+ return receiver_spec
189
502
  if ns == "map" and func == "new":
190
503
  key = self._type_spec_from_hint(targs[0]) if len(targs) > 0 else TypeSpec.primitive("string")
191
504
  val = self._type_spec_from_hint(targs[1]) if len(targs) > 1 else TypeSpec.primitive("float")
192
505
  return TypeSpec.map(key or TypeSpec.primitive("string"), val or TypeSpec.primitive("float"))
506
+ if ns == "map" and func in {
507
+ "put", "get", "remove", "contains", "size", "keys",
508
+ "values", "copy", "put_all", "clear",
509
+ } and value.args:
510
+ recv_spec = self._type_spec_from_expr(value.args[0])
511
+ if recv_spec is not None and recv_spec.kind == "map":
512
+ if func in ("put", "get", "remove"):
513
+ return recv_spec.value
514
+ if func == "keys":
515
+ return TypeSpec.array(
516
+ recv_spec.key or TypeSpec.primitive("string")
517
+ )
518
+ if func == "values":
519
+ return TypeSpec.array(
520
+ recv_spec.value or TypeSpec.primitive("float")
521
+ )
522
+ if func == "copy":
523
+ return recv_spec
524
+ if func == "contains":
525
+ return TypeSpec.primitive("bool")
526
+ if func == "size":
527
+ return TypeSpec.primitive("int")
193
528
  if ns == "str" and func == "split":
194
529
  return TypeSpec.array(TypeSpec.primitive("string"))
195
530
  if ns == "ta" and func == "pivot_point_levels":
@@ -206,7 +541,7 @@ class TypeHelper:
206
541
  if inner is None:
207
542
  return TypeSpec.array(TypeSpec.primitive("float"))
208
543
  return TypeSpec.array(self._pine_type_to_spec(inner))
209
- if ns in self._udt_fields and func == "new":
544
+ if ns in self._udt_fields and func in {"new", "copy"}:
210
545
  return TypeSpec.udt(ns)
211
546
  if isinstance(cal, MemberAccess):
212
547
  recv_spec = self._type_spec_from_expr(cal.object)
@@ -216,14 +551,20 @@ class TypeHelper:
216
551
  if func in ("copy", "slice"):
217
552
  return recv_spec
218
553
  if recv_spec is not None and recv_spec.kind == "map":
219
- if func in ("get", "remove"):
554
+ if func in ("put", "get", "remove"):
220
555
  return recv_spec.value
221
556
  if func == "keys":
222
557
  return TypeSpec.array(recv_spec.key or TypeSpec.primitive("string"))
223
558
  if func == "values":
224
559
  return TypeSpec.array(recv_spec.value or TypeSpec.primitive("float"))
560
+ if func == "copy":
561
+ return recv_spec
562
+ if func == "contains":
563
+ return TypeSpec.primitive("bool")
564
+ if func == "size":
565
+ return TypeSpec.primitive("int")
225
566
  if recv_spec is not None and recv_spec.kind == "matrix":
226
- if func in ("copy", "submatrix", "transpose", "concat"):
567
+ if func in _MATRIX_RETURNING_METHODS:
227
568
  return recv_spec
228
569
  if func in ("row", "col"):
229
570
  return TypeSpec.array(recv_spec.element)
@@ -231,6 +572,25 @@ class TypeHelper:
231
572
  return recv_spec.element
232
573
  if func == "eigenvalues":
233
574
  return TypeSpec.array(TypeSpec.primitive("float"))
575
+ receiver_name = method_receiver_type_name(recv_spec)
576
+ if receiver_name is not None:
577
+ method_key = f"{receiver_name}.{func}"
578
+ method_info = next(
579
+ (
580
+ info
581
+ for info in getattr(self, "_func_infos", ())
582
+ if info.name == method_key
583
+ and getattr(info, "is_udt_method", False)
584
+ ),
585
+ None,
586
+ )
587
+ return_spec = getattr(
588
+ method_info, "return_type_spec", None
589
+ )
590
+ if return_spec is not None:
591
+ return return_spec
592
+ if method_key in getattr(self, "_method_signatures", {}):
593
+ return None
234
594
  # Drawing method-form: a.copy() -> same handle; lf.get_line*() -> line.
235
595
  if (recv_spec is not None and recv_spec.kind == "udt"
236
596
  and recv_spec.name in _DRAWING_TYPE_NAMES):
@@ -240,23 +600,600 @@ class TypeHelper:
240
600
  return TypeSpec.udt("line")
241
601
  if isinstance(value, Identifier):
242
602
  sym = self._symbols.resolve(value.name)
243
- if sym is not None and sym.type_spec is not None:
244
- return sym.type_spec
603
+ if sym is not None:
604
+ if sym.type_spec is not None:
605
+ return sym.type_spec
606
+ if sym.pine_type in {
607
+ PineType.INT,
608
+ PineType.FLOAT,
609
+ PineType.BOOL,
610
+ PineType.STRING,
611
+ PineType.COLOR,
612
+ }:
613
+ return self._pine_type_to_spec(sym.pine_type)
245
614
  if isinstance(value, FuncCall):
246
615
  # User-function return spec (e.g. an array-returning
247
616
  # ``buildPDLevels() => array.from(...)``), so a caller's
248
617
  # ``allLevels = buildPDLevels()`` infers an array TypeSpec.
249
618
  cal = value.callee
250
- fname = cal.member if isinstance(cal, MemberAccess) else (
251
- cal.name if isinstance(cal, Identifier) else None)
619
+ if isinstance(cal, MemberAccess):
620
+ receiver_name = method_receiver_type_name(
621
+ self._type_spec_from_expr(cal.object)
622
+ )
623
+ fname = (
624
+ f"{receiver_name}.{cal.member}"
625
+ if receiver_name is not None
626
+ else cal.member
627
+ )
628
+ else:
629
+ fname = cal.name if isinstance(cal, Identifier) else None
252
630
  if fname and fname in getattr(self, "_func_return_type_specs", {}):
253
631
  return self._func_return_type_specs[fname]
632
+ if fname and fname in getattr(self, "_func_udt_return_types", {}):
633
+ udt_return = self._func_udt_return_types[fname]
634
+ if udt_return in _DRAWING_TYPE_NAMES:
635
+ return TypeSpec.udt(udt_return)
254
636
  if isinstance(value, MemberAccess):
255
637
  owner = self._type_spec_from_expr(value.object)
256
638
  if owner is not None and owner.kind == "udt" and owner.name:
257
639
  return (self._udt_field_type_specs.get(owner.name) or {}).get(value.member)
258
640
  return None
259
641
 
642
+ @staticmethod
643
+ def _direct_terminal_return_expr(func_def) -> ASTNode | None:
644
+ """Return a UDF's direct terminal expression, if it has one."""
645
+ if not func_def.body:
646
+ return None
647
+ last_stmt = func_def.body[-1]
648
+ if isinstance(last_stmt, ExprStmt):
649
+ return last_stmt.expr
650
+ if not isinstance(last_stmt, TupleLiteral) and hasattr(last_stmt, "loc"):
651
+ return last_stmt
652
+ return None
653
+
654
+ def _terminal_map_selection_return_spec(
655
+ self,
656
+ value: ASTNode | None,
657
+ parameter_specs: dict[str, TypeSpec | None],
658
+ ) -> TypeSpec | None:
659
+ """Infer a map returned by a terminal ternary/block selection.
660
+
661
+ Untyped UDF parameters do not have a lexical ``TypeSpec`` while their
662
+ definition is first analyzed. At a concrete call site, however, the
663
+ argument specs are known. Propagate those specs through only the two
664
+ Pine selection shapes whose result type is determined by compatible
665
+ branches: ``condition ? a : b`` and terminal ``if`` expressions.
666
+
667
+ A direct ``na`` arm is context-typed by the opposite map arm. All
668
+ other unresolved or incompatible shapes return ``None`` so this helper
669
+ cannot turn a scalar/non-map UDF into a map-returning function.
670
+ """
671
+
672
+ def branch_terminal(node: ASTNode | None) -> ASTNode | None:
673
+ if not isinstance(node, IfStmt):
674
+ return node
675
+ if not node.body or not node.else_body:
676
+ return None
677
+ return node
678
+
679
+ def body_terminal(body: list[ASTNode] | None) -> ASTNode | None:
680
+ if not body:
681
+ return None
682
+ terminal = body[-1]
683
+ return terminal.expr if isinstance(terminal, ExprStmt) else terminal
684
+
685
+ def resolve(node: ASTNode | None) -> TypeSpec | None:
686
+ if node is None or isinstance(node, NaLiteral):
687
+ return None
688
+ if isinstance(node, Identifier) and node.name in parameter_specs:
689
+ spec = parameter_specs[node.name]
690
+ return spec if spec is not None and spec.kind == "map" else None
691
+ if isinstance(node, Ternary):
692
+ return compatible(node.true_val, node.false_val)
693
+ if isinstance(node, IfStmt):
694
+ return compatible(
695
+ body_terminal(node.body),
696
+ body_terminal(node.else_body),
697
+ )
698
+ spec = self._type_spec_from_expr(node)
699
+ return spec if spec is not None and spec.kind == "map" else None
700
+
701
+ def compatible(
702
+ left_node: ASTNode | None,
703
+ right_node: ASTNode | None,
704
+ ) -> TypeSpec | None:
705
+ left_node = branch_terminal(left_node)
706
+ right_node = branch_terminal(right_node)
707
+ if left_node is None or right_node is None:
708
+ return None
709
+ left = resolve(left_node)
710
+ right = resolve(right_node)
711
+ if left is not None and right is not None:
712
+ return left if left == right else None
713
+ if left is not None and isinstance(right_node, NaLiteral):
714
+ return left
715
+ if right is not None and isinstance(left_node, NaLiteral):
716
+ return right
717
+ return None
718
+
719
+ if isinstance(value, Ternary):
720
+ return compatible(value.true_val, value.false_val)
721
+ if isinstance(value, IfStmt):
722
+ return compatible(
723
+ body_terminal(value.body),
724
+ body_terminal(value.else_body),
725
+ )
726
+ return None
727
+
728
+ def _terminal_map_call_return(
729
+ self,
730
+ value: ASTNode | None,
731
+ parameter_specs: dict[str, TypeSpec | None] | None = None,
732
+ ) -> tuple[PineType, TypeSpec | None] | None:
733
+ """Return metadata for a direct terminal map call in a UDF.
734
+
735
+ This deliberately does not participate in general expression or
736
+ declaration inference. During function-definition analysis it uses
737
+ the active lexical scope for locals and typed parameters; for untyped
738
+ parameters it may be called again with call-site ``parameter_specs``.
739
+ Arbitrary-expression receivers stay out of scope because their map
740
+ method routing is not supported yet.
741
+ """
742
+ if not isinstance(value, FuncCall) or not isinstance(
743
+ value.callee, MemberAccess
744
+ ):
745
+ return None
746
+
747
+ callee = value.callee
748
+ method = callee.member
749
+ receiver = None
750
+ if isinstance(callee.object, Identifier) and callee.object.name == "map":
751
+ # Functional form: map.method(id, ...). Keyword-only receiver
752
+ # routing remains a separate residual, so require the established
753
+ # positional receiver here.
754
+ functional_arity = {
755
+ "clear": 1,
756
+ "keys": 1,
757
+ "values": 1,
758
+ "copy": 1,
759
+ "put_all": 2,
760
+ "get": 2,
761
+ "remove": 2,
762
+ "put": 3,
763
+ }.get(method)
764
+ if (
765
+ functional_arity is not None
766
+ and len(value.args) == functional_arity
767
+ and not value.kwargs
768
+ ):
769
+ receiver = value.args[0]
770
+ elif isinstance(callee.object, Identifier):
771
+ # Global/local/typed-parameter method forms only. Do not infer an
772
+ # arbitrary expression receiver that codegen cannot route.
773
+ is_parameter = (
774
+ parameter_specs is not None
775
+ and callee.object.name in parameter_specs
776
+ )
777
+ valid_method_shape = False
778
+ expected_arity = {
779
+ "clear": 0,
780
+ "keys": 0,
781
+ "values": 0,
782
+ "copy": 0,
783
+ "put_all": 1,
784
+ "get": 1,
785
+ "remove": 1,
786
+ "put": 2,
787
+ }.get(method)
788
+ if expected_arity is not None:
789
+ valid_method_shape = (
790
+ len(value.args) == expected_arity and not value.kwargs
791
+ )
792
+ if is_parameter and not valid_method_shape:
793
+ if method == "put_all":
794
+ valid_method_shape = (
795
+ not value.args and set(value.kwargs) == {"id2"}
796
+ )
797
+ elif method in ("get", "remove"):
798
+ valid_method_shape = (
799
+ not value.args and set(value.kwargs) == {"key"}
800
+ )
801
+ elif method == "put":
802
+ valid_method_shape = (
803
+ (
804
+ len(value.args) == 1
805
+ and set(value.kwargs) == {"value"}
806
+ )
807
+ or (
808
+ not value.args
809
+ and set(value.kwargs) == {"key", "value"}
810
+ )
811
+ )
812
+ if valid_method_shape:
813
+ receiver = callee.object
814
+ if receiver is None:
815
+ return None
816
+
817
+ recv_spec = None
818
+ if (
819
+ isinstance(receiver, Identifier)
820
+ and parameter_specs is not None
821
+ and receiver.name in parameter_specs
822
+ ):
823
+ recv_spec = parameter_specs.get(receiver.name)
824
+ # An unresolved parameter still shadows any same-named global.
825
+ if recv_spec is None:
826
+ return None
827
+ else:
828
+ recv_spec = self._type_spec_from_expr(receiver)
829
+ if recv_spec is None or recv_spec.kind != "map":
830
+ return None
831
+
832
+ if method in ("clear", "put_all"):
833
+ return PineType.VOID, None
834
+ if method == "keys":
835
+ return (
836
+ PineType.VOID,
837
+ TypeSpec.array(recv_spec.key or TypeSpec.primitive("string")),
838
+ )
839
+ if method == "values":
840
+ return (
841
+ PineType.VOID,
842
+ TypeSpec.array(recv_spec.value or TypeSpec.primitive("float")),
843
+ )
844
+ if method == "copy":
845
+ return PineType.VOID, recv_spec
846
+ if (
847
+ method in ("put", "get", "remove")
848
+ and recv_spec.value is not None
849
+ and recv_spec.value.kind == "primitive"
850
+ and recv_spec.value.name == "string"
851
+ ):
852
+ return PineType.STRING, None
853
+ return None
854
+
855
+ def _terminal_array_get_receiver(
856
+ self,
857
+ value: ASTNode | None,
858
+ ) -> ASTNode | None:
859
+ """Return a lexically valid receiver for a direct terminal get."""
860
+ if not isinstance(value, FuncCall) or not isinstance(
861
+ value.callee, MemberAccess
862
+ ):
863
+ return None
864
+ callee = value.callee
865
+ if callee.member != "get":
866
+ return None
867
+
868
+ functional = (
869
+ isinstance(callee.object, Identifier)
870
+ and callee.object.name == "array"
871
+ and self._symbols.resolve("array") is None
872
+ )
873
+ if functional:
874
+ if len(value.args) == 2 and not value.kwargs:
875
+ return value.args[0]
876
+ if len(value.args) == 1 and set(value.kwargs) == {"index"}:
877
+ return value.args[0]
878
+ if not value.args and set(value.kwargs) == {"id", "index"}:
879
+ return value.kwargs["id"]
880
+ return None
881
+
882
+ if len(value.args) == 1 and not value.kwargs:
883
+ return callee.object
884
+ if not value.args and set(value.kwargs) == {"index"}:
885
+ return callee.object
886
+ return None
887
+
888
+ @staticmethod
889
+ def _direct_namespace_array_copy_source(
890
+ value: ASTNode | None,
891
+ ) -> ASTNode | None:
892
+ """Return the sole receiver of an exact ``array.copy`` call shape.
893
+
894
+ Pine v6 accepts either ``array.copy(source)`` or
895
+ ``array.copy(id=source)``. Keep this structural helper exact so an
896
+ invalid duplicate receiver or an unrelated keyword stays fail closed.
897
+ Namespace shadowing is intentionally checked by the callers whose
898
+ lexical scope is still live.
899
+ """
900
+ if not isinstance(value, FuncCall) or not isinstance(
901
+ value.callee, MemberAccess
902
+ ):
903
+ return None
904
+ callee = value.callee
905
+ if not (
906
+ isinstance(callee.object, Identifier)
907
+ and callee.object.name == "array"
908
+ and callee.member == "copy"
909
+ ):
910
+ return None
911
+ if len(value.args) == 1 and not value.kwargs:
912
+ return value.args[0]
913
+ if not value.args and set(value.kwargs) == {"id"}:
914
+ return value.kwargs["id"]
915
+ return None
916
+
917
+ def _is_unshadowed_direct_array_value_producer(
918
+ self,
919
+ value: ASTNode | None,
920
+ ) -> bool:
921
+ """Whether ``value`` is one direct built-in array value producer."""
922
+ if not isinstance(value, FuncCall) or not isinstance(
923
+ value.callee, MemberAccess
924
+ ):
925
+ return False
926
+ callee = value.callee
927
+ namespace_producer = (
928
+ isinstance(callee.object, Identifier)
929
+ and callee.object.name == "array"
930
+ and self._symbols.resolve("array") is None
931
+ and callee.member in _DIRECT_ARRAY_VALUE_PRODUCERS
932
+ )
933
+ if namespace_producer:
934
+ if callee.member != "copy":
935
+ return True
936
+ source = self._direct_namespace_array_copy_source(value)
937
+ if source is None:
938
+ return False
939
+ if isinstance(source, Identifier):
940
+ source_spec = self._type_spec_from_expr(source)
941
+ return source_spec is not None and source_spec.kind == "array"
942
+ return self._is_unshadowed_direct_array_value_producer(source)
943
+ if callee.member != "copy" or value.args or value.kwargs:
944
+ return False
945
+ if isinstance(callee.object, Identifier):
946
+ receiver_spec = self._type_spec_from_expr(callee.object)
947
+ return receiver_spec is not None and receiver_spec.kind == "array"
948
+ # A no-argument method copy preserves the value type of an existing
949
+ # direct producer. Recurse only through that already-bounded shape;
950
+ # arbitrary UDF/slice/map receivers remain excluded.
951
+ return self._is_unshadowed_direct_array_value_producer(callee.object)
952
+
953
+ def _direct_array_value_spec_without_visiting(
954
+ self,
955
+ value: ASTNode | None,
956
+ ) -> TypeSpec | None:
957
+ """Exact direct-producer spec without re-visiting a nested call."""
958
+ cached = self._cached_direct_array_value_spec(value)
959
+ if cached is not None:
960
+ return cached
961
+ if not isinstance(value, FuncCall) or not isinstance(
962
+ value.callee, MemberAccess
963
+ ):
964
+ return None
965
+ callee = value.callee
966
+ source: ASTNode | None = None
967
+ if (
968
+ isinstance(callee.object, Identifier)
969
+ and callee.object.name == "array"
970
+ and self._symbols.resolve("array") is None
971
+ and callee.member == "copy"
972
+ ):
973
+ candidate = self._direct_namespace_array_copy_source(value)
974
+ if isinstance(candidate, Identifier):
975
+ source = candidate
976
+ elif callee.member == "copy" and isinstance(
977
+ callee.object, Identifier
978
+ ):
979
+ source = callee.object
980
+ if source is None:
981
+ return None
982
+ spec = self._type_spec_from_expr(source)
983
+ return spec if spec is not None and spec.kind == "array" else None
984
+
985
+ def _terminal_array_get_uses_direct_temporary(
986
+ self,
987
+ value: ASTNode | None,
988
+ ) -> bool:
989
+ """Capture the narrow temporary shape while lexical scope is live."""
990
+ receiver = self._terminal_array_get_receiver(value)
991
+ return (
992
+ receiver is not None
993
+ and not isinstance(receiver, Identifier)
994
+ and self._is_unshadowed_direct_array_value_producer(receiver)
995
+ )
996
+
997
+ def _cached_primitive_expr_spec(
998
+ self,
999
+ value: ASTNode | None,
1000
+ ) -> TypeSpec | None:
1001
+ """Resolve a primitive without visiting calls or lexical symbols.
1002
+
1003
+ This helper exists only for the bounded forward-order reconciliation
1004
+ of direct temporary array producers. Calling ``_visit`` here would
1005
+ allocate stateful UDF call sites a second time.
1006
+ """
1007
+ if isinstance(value, NumberLiteral):
1008
+ return TypeSpec.primitive(
1009
+ "float" if isinstance(value.value, float) else "int"
1010
+ )
1011
+ if isinstance(value, BoolLiteral):
1012
+ return TypeSpec.primitive("bool")
1013
+ if isinstance(value, StringLiteral):
1014
+ return TypeSpec.primitive("string")
1015
+ if isinstance(value, UnaryOp) and value.op in ("+", "-"):
1016
+ return self._cached_primitive_expr_spec(value.operand)
1017
+ if isinstance(value, FuncCall) and isinstance(value.callee, Identifier):
1018
+ name = value.callee.name
1019
+ spec = getattr(self, "_func_return_type_specs", {}).get(name)
1020
+ if spec is not None and spec.kind == "primitive":
1021
+ return spec
1022
+ pine_type = getattr(self, "_func_return_types", {}).get(name)
1023
+ primitive = {
1024
+ PineType.INT: "int",
1025
+ PineType.FLOAT: "float",
1026
+ PineType.BOOL: "bool",
1027
+ PineType.STRING: "string",
1028
+ PineType.COLOR: "color",
1029
+ }.get(pine_type)
1030
+ if primitive is not None:
1031
+ return TypeSpec.primitive(primitive)
1032
+ return None
1033
+
1034
+ def _cached_direct_array_value_spec(
1035
+ self,
1036
+ value: ASTNode | None,
1037
+ ) -> TypeSpec | None:
1038
+ """Resolve one registered direct producer without analyzer effects."""
1039
+ if not isinstance(value, FuncCall) or not isinstance(
1040
+ value.callee, MemberAccess
1041
+ ):
1042
+ return None
1043
+ callee = value.callee
1044
+ if callee.member == "copy" and not value.args and not value.kwargs:
1045
+ # Method syntax carries its source in the callee object rather
1046
+ # than in ``args``. Peel only a direct producer and stay entirely
1047
+ # on cached metadata so a stateful element call is never revisited.
1048
+ return self._cached_direct_array_value_spec(callee.object)
1049
+ if not (
1050
+ isinstance(callee.object, Identifier)
1051
+ and callee.object.name == "array"
1052
+ and callee.member in _DIRECT_ARRAY_VALUE_PRODUCERS
1053
+ ):
1054
+ return None
1055
+
1056
+ producer = callee.member
1057
+ typed = {
1058
+ "new_float": "float",
1059
+ "new_int": "int",
1060
+ "new_bool": "bool",
1061
+ "new_string": "string",
1062
+ }.get(producer)
1063
+ if typed is not None:
1064
+ return TypeSpec.array(TypeSpec.primitive(typed))
1065
+ if producer == "new":
1066
+ targs = self._template_args_from_call(value)
1067
+ if targs:
1068
+ element = self._type_spec_from_hint(targs[0])
1069
+ if element is None or element.kind != "primitive":
1070
+ return None
1071
+ return TypeSpec.array(element)
1072
+ return TypeSpec.array(TypeSpec.primitive("float"))
1073
+ if producer == "from" and value.args:
1074
+ element = self._cached_primitive_expr_spec(value.args[0])
1075
+ return TypeSpec.array(element) if element is not None else None
1076
+ if producer == "copy":
1077
+ source = self._direct_namespace_array_copy_source(value)
1078
+ if source is not None:
1079
+ return self._cached_direct_array_value_spec(source)
1080
+ return None
1081
+
1082
+ def _cached_terminal_temporary_array_get_spec(
1083
+ self,
1084
+ value: ASTNode | None,
1085
+ ) -> TypeSpec | None:
1086
+ """Side-effect-free element spec for a preregistered temporary get."""
1087
+ if not isinstance(value, FuncCall) or not isinstance(
1088
+ value.callee, MemberAccess
1089
+ ):
1090
+ return None
1091
+ callee = value.callee
1092
+ if callee.member != "get":
1093
+ return None
1094
+ if isinstance(callee.object, Identifier) and callee.object.name == "array":
1095
+ if len(value.args) == 2 and not value.kwargs:
1096
+ receiver = value.args[0]
1097
+ elif len(value.args) == 1 and set(value.kwargs) == {"index"}:
1098
+ receiver = value.args[0]
1099
+ elif not value.args and set(value.kwargs) == {"id", "index"}:
1100
+ receiver = value.kwargs["id"]
1101
+ else:
1102
+ return None
1103
+ elif len(value.args) == 1 and not value.kwargs:
1104
+ receiver = callee.object
1105
+ elif not value.args and set(value.kwargs) == {"index"}:
1106
+ receiver = callee.object
1107
+ else:
1108
+ return None
1109
+
1110
+ receiver_spec = self._cached_direct_array_value_spec(receiver)
1111
+ if (
1112
+ receiver_spec is None
1113
+ or receiver_spec.kind != "array"
1114
+ or receiver_spec.element is None
1115
+ or receiver_spec.element.kind != "primitive"
1116
+ ):
1117
+ return None
1118
+ return receiver_spec.element
1119
+
1120
+ def _terminal_array_get_return(
1121
+ self,
1122
+ value: ASTNode | None,
1123
+ parameter_specs: dict[str, TypeSpec | None] | None = None,
1124
+ resolved_return_spec: TypeSpec | None = None,
1125
+ ) -> tuple[PineType, TypeSpec] | None:
1126
+ """Return exact metadata for a direct terminal ``array.get`` call.
1127
+
1128
+ The regular expression ``TypeSpec`` pass already knows the element
1129
+ type of both ``array.get(values, index)`` and ``values.get(index)``.
1130
+ The coarse visitor, however, deliberately returns ``VOID`` for most
1131
+ array methods. When such a call is the final expression of a UDF,
1132
+ that ``VOID`` used to make codegen emit a ``double`` return type even
1133
+ for primitive elements such as strings, booleans, and integers.
1134
+
1135
+ Keep the refinement intentionally narrow: only the established
1136
+ positional/keyword shapes of ``get`` participate, and the receiver
1137
+ must resolve to an exact array ``TypeSpec``. In addition to exact
1138
+ identifiers, direct built-in array producers (``array.from/new/copy``)
1139
+ may use the terminal expression spec that the caller already captured
1140
+ while the lexical scope was active. Reusing that snapshot is
1141
+ load-bearing: re-inferring an effectful temporary here would revisit
1142
+ nested calls and mint phantom call-site state. Other array accessors,
1143
+ mutations, range/view semantics, arbitrary UDF receivers,
1144
+ reference/ID-like elements, and unresolved receivers remain on their
1145
+ existing paths. Returning UDTs or nested collections by value would
1146
+ lose Pine reference identity even when the generated C++ happens to
1147
+ compile, so this helper must not expose those specs as an apparent fix.
1148
+ """
1149
+ receiver = self._terminal_array_get_receiver(value)
1150
+ if receiver is None:
1151
+ return None
1152
+ if not isinstance(receiver, Identifier):
1153
+ # Both functional and method forms stay inside the same narrow
1154
+ # contract. A temporary UDF, slice/view, map result, or a local
1155
+ # binding merely named ``array`` must not enter this refinement.
1156
+ if not self._is_unshadowed_direct_array_value_producer(receiver):
1157
+ return None
1158
+ if resolved_return_spec is None:
1159
+ producer_spec = self._direct_array_value_spec_without_visiting(
1160
+ receiver
1161
+ )
1162
+ if producer_spec is not None:
1163
+ resolved_return_spec = producer_spec.element
1164
+ if (
1165
+ resolved_return_spec is None
1166
+ or resolved_return_spec.kind != "primitive"
1167
+ ):
1168
+ return None
1169
+ return_type = self._element_pine_type(resolved_return_spec)
1170
+ if return_type == PineType.VOID:
1171
+ return None
1172
+ return return_type, resolved_return_spec
1173
+
1174
+ recv_spec = None
1175
+ if parameter_specs is not None and receiver.name in parameter_specs:
1176
+ recv_spec = parameter_specs.get(receiver.name)
1177
+ # An unresolved parameter shadows any same-named global.
1178
+ if recv_spec is None:
1179
+ return None
1180
+ else:
1181
+ recv_spec = self._type_spec_from_expr(receiver)
1182
+ if (
1183
+ recv_spec is None
1184
+ or recv_spec.kind != "array"
1185
+ or recv_spec.element is None
1186
+ ):
1187
+ return None
1188
+
1189
+ element_spec = recv_spec.element
1190
+ if element_spec.kind != "primitive":
1191
+ return None
1192
+ return_type = self._element_pine_type(element_spec)
1193
+ if return_type == PineType.VOID:
1194
+ return None
1195
+ return return_type, element_spec
1196
+
260
1197
  @staticmethod
261
1198
  def _pine_type_to_spec(pine_type: PineType) -> TypeSpec:
262
1199
  mapping = {