@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.
@@ -0,0 +1,163 @@
1
+ """Strict, source-order-preserving binding for typed Pine methods.
2
+
3
+ Typed extension methods are inventoried before semantic analysis so a call may
4
+ precede its declaration without falling through to a same-named builtin. This
5
+ module deliberately contains no analyzer or codegen state: both phases consume
6
+ the same signature and binding result.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from .ast_nodes import ASTNode, FuncCall, MethodDef, Program
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MethodSignature:
18
+ key: str
19
+ param_names: tuple[str, ...]
20
+ param_defaults: tuple[ASTNode | None, ...]
21
+ param_type_hints: tuple[str | None, ...]
22
+ declaration: MethodDef | None = None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class BoundMethodArgs:
27
+ """Non-receiver arguments in parameter and evaluation order."""
28
+
29
+ args_by_param: tuple[ASTNode, ...]
30
+ evaluation_order: tuple[ASTNode, ...]
31
+
32
+
33
+ class MethodBindError(ValueError):
34
+ pass
35
+
36
+
37
+ def signature_from_method_def(node: MethodDef) -> MethodSignature:
38
+ annotations = node.annotations or {}
39
+ defaults = list(annotations.get("param_defaults", ()))
40
+ hints = list(annotations.get("param_type_hints", ()))
41
+ while len(defaults) < len(node.params):
42
+ defaults.append(None)
43
+ while len(hints) < len(node.params):
44
+ hints.append(None)
45
+ return MethodSignature(
46
+ key=f"{node.type_name}.{node.name}",
47
+ param_names=tuple(node.params[1:]),
48
+ param_defaults=tuple(defaults[1:len(node.params)]),
49
+ param_type_hints=tuple(hints[1:len(node.params)]),
50
+ declaration=node,
51
+ )
52
+
53
+
54
+ def signature_from_callable(
55
+ key: str,
56
+ param_names: list[str] | tuple[str, ...],
57
+ param_defaults: list[ASTNode | None] | tuple[ASTNode | None, ...],
58
+ param_type_hints: list[str | None] | tuple[str | None, ...] = (),
59
+ ) -> MethodSignature:
60
+ """Build a non-receiver signature from an analyzed method FuncInfo."""
61
+
62
+ names = tuple(param_names)
63
+ defaults = list(param_defaults)
64
+ hints = list(param_type_hints)
65
+ while len(defaults) < len(names):
66
+ defaults.append(None)
67
+ while len(hints) < len(names):
68
+ hints.append(None)
69
+ return MethodSignature(
70
+ key=key,
71
+ param_names=names,
72
+ param_defaults=tuple(defaults[:len(names)]),
73
+ param_type_hints=tuple(hints[:len(names)]),
74
+ )
75
+
76
+
77
+ def inventory_method_signatures(program: Program) -> dict[str, MethodSignature]:
78
+ """Inventory direct method declarations without analyzing their bodies."""
79
+
80
+ result: dict[str, MethodSignature] = {}
81
+ for stmt in program.body:
82
+ if isinstance(stmt, MethodDef):
83
+ signature = signature_from_method_def(stmt)
84
+ # Preserve the analyzer's historical first-declaration lookup. This
85
+ # prepass does not introduce method overloading or duplicate policy.
86
+ result.setdefault(signature.key, signature)
87
+ return result
88
+
89
+
90
+ def _written_actuals(call: FuncCall) -> list[ASTNode]:
91
+ """Recover written order lost by the parser's args/kwargs split."""
92
+
93
+ fallback = [*call.args, *call.kwargs.values()]
94
+ recorded = (call.annotations or {}).get("call_arg_order", ())
95
+ if (
96
+ len(recorded) == len(fallback)
97
+ and {id(node) for node in recorded} == {id(node) for node in fallback}
98
+ ):
99
+ return list(recorded)
100
+ if not fallback or any(getattr(node, "loc", None) is None for node in fallback):
101
+ return fallback
102
+ indexed = list(enumerate(fallback))
103
+ indexed.sort(
104
+ key=lambda item: (
105
+ item[1].loc.line,
106
+ item[1].loc.col,
107
+ item[0],
108
+ )
109
+ )
110
+ return [node for _index, node in indexed]
111
+
112
+
113
+ def bind_method_call(
114
+ signature: MethodSignature,
115
+ call: FuncCall,
116
+ ) -> BoundMethodArgs:
117
+ """Bind one method call strictly, excluding the receiver argument."""
118
+
119
+ names = signature.param_names
120
+ if len(call.args) > len(names):
121
+ raise MethodBindError(
122
+ f"{signature.key}: too many positional arguments "
123
+ f"(expected {len(names)}, got {len(call.args)})"
124
+ )
125
+
126
+ for name in call.kwargs:
127
+ if name not in names:
128
+ raise MethodBindError(
129
+ f"{signature.key}: unknown keyword argument '{name}'"
130
+ )
131
+
132
+ bound: list[ASTNode | None] = [None] * len(names)
133
+ for index, value in enumerate(call.args):
134
+ bound[index] = value
135
+ for name, value in call.kwargs.items():
136
+ index = names.index(name)
137
+ if bound[index] is not None:
138
+ raise MethodBindError(
139
+ f"{signature.key}: argument '{name}' passed both "
140
+ "positionally and by keyword"
141
+ )
142
+ bound[index] = value
143
+
144
+ inserted_defaults: list[ASTNode] = []
145
+ for index, name in enumerate(names):
146
+ if bound[index] is not None:
147
+ continue
148
+ default = (
149
+ signature.param_defaults[index]
150
+ if index < len(signature.param_defaults)
151
+ else None
152
+ )
153
+ if default is None:
154
+ raise MethodBindError(
155
+ f"{signature.key}: missing required argument '{name}'"
156
+ )
157
+ bound[index] = default
158
+ inserted_defaults.append(default)
159
+
160
+ return BoundMethodArgs(
161
+ args_by_param=tuple(value for value in bound if value is not None),
162
+ evaluation_order=tuple([*_written_actuals(call), *inserted_defaults]),
163
+ )
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import re
15
15
 
16
16
  from .lexer import Token, TokenType
17
- from .errors import SourceLocation
17
+ from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation
18
18
  from .ast_nodes import (
19
19
  ASTNode,
20
20
  Program, StrategyDecl, ImportStmt,
@@ -56,6 +56,7 @@ class Parser:
56
56
  self.pos = 0
57
57
  self._source = source
58
58
  self._filename = filename
59
+ self._recovery_count = 0
59
60
 
60
61
  # ------------------------------------------------------------------
61
62
  # Helpers
@@ -143,6 +144,9 @@ class Parser:
143
144
  self._recover()
144
145
  self._skip_newlines()
145
146
 
147
+ if self._recovery_count:
148
+ prog.annotations = dict(prog.annotations or {})
149
+ prog.annotations["parse_recovery_count"] = self._recovery_count
146
150
  return prog
147
151
 
148
152
  def _extract_version(self) -> int | None:
@@ -156,6 +160,7 @@ class Parser:
156
160
 
157
161
  def _recover(self) -> None:
158
162
  """Skip tokens until next NEWLINE or EOF for error recovery."""
163
+ self._recovery_count += 1
159
164
  while not self._at_end() and not self._check(TokenType.NEWLINE):
160
165
  self._advance()
161
166
  if self._check(TokenType.NEWLINE):
@@ -400,10 +405,13 @@ class Parser:
400
405
  start_tok = self._advance() # consume 'strategy' or 'indicator'
401
406
  # Parse arguments as a function call, then convert to StrategyDecl
402
407
  self._consume(TokenType.LPAREN)
403
- args, kwargs = self._parse_call_args()
408
+ args, kwargs, call_arg_order = self._parse_call_args()
404
409
  self._consume(TokenType.RPAREN)
405
410
  node = StrategyDecl(args=args, kwargs=kwargs)
406
- node.annotations = {"decl_kind": start_tok.value}
411
+ node.annotations = {
412
+ "decl_kind": start_tok.value,
413
+ "call_arg_order": call_arg_order,
414
+ }
407
415
  return self._set_loc(node, start_tok)
408
416
 
409
417
  def _parse_import_stmt(self) -> ImportStmt:
@@ -786,8 +794,16 @@ class Parser:
786
794
  start_tok = self._advance() # consume 'method'
787
795
  name = self._consume(TokenType.IDENT).value
788
796
  self._consume(TokenType.LPAREN)
789
- # First param is the type + self: TypeName self
790
- type_name = self._consume(TokenType.IDENT).value
797
+ # First param is the required receiver type + self name. Receiver
798
+ # types use the same complete Pine hint grammar as ordinary typed
799
+ # parameters: primitives lex as TYPE_* tokens, while collections may
800
+ # carry nested ``<...>`` arguments or postfix ``[]``. Consuming only
801
+ # IDENT here silently dropped valid declarations such as
802
+ # ``method id(int self)`` and ``method push(array<int> self, ...)``
803
+ # through the parser's top-level recovery path.
804
+ if self._current().type not in TYPE_KEYWORDS | {TokenType.IDENT}:
805
+ self._consume(TokenType.IDENT) # raises a located ParseError
806
+ type_name = self._parse_type_hint_string()
791
807
  params = [self._consume(TokenType.IDENT).value] # 'self' or user's name
792
808
  param_type_hints = [type_name]
793
809
  # Preserve per-param default expressions so codegen can substitute
@@ -1212,15 +1228,17 @@ class Parser:
1212
1228
  """Parse (args, kwargs) after callee expression."""
1213
1229
  start_tok = self._current()
1214
1230
  self._consume(TokenType.LPAREN)
1215
- args, kwargs = self._parse_call_args()
1231
+ args, kwargs, call_arg_order = self._parse_call_args()
1216
1232
  self._consume(TokenType.RPAREN)
1217
1233
  node = FuncCall(callee=callee, args=args, kwargs=kwargs)
1234
+ node.annotations = {"call_arg_order": call_arg_order}
1218
1235
  return self._set_loc(node, start_tok)
1219
1236
 
1220
- def _parse_call_args(self) -> tuple[list, dict]:
1237
+ def _parse_call_args(self) -> tuple[list, dict, list]:
1221
1238
  """Parse function call arguments and keyword arguments."""
1222
1239
  args: list = []
1223
1240
  kwargs: dict = {}
1241
+ call_arg_order: list = []
1224
1242
 
1225
1243
  while not self._check(TokenType.RPAREN) and not self._at_end():
1226
1244
  # Detect kwargs: IDENT = value (but not IDENT == value)
@@ -1228,15 +1246,36 @@ class Parser:
1228
1246
  and self._peek().type == TokenType.EQUALS
1229
1247
  and self._peek(2).type != TokenType.EQUALS):
1230
1248
  key_tok = self._advance()
1249
+ if key_tok.value in kwargs:
1250
+ raise CompileError(
1251
+ [
1252
+ Diagnostic(
1253
+ level=Level.ERROR,
1254
+ phase=Phase.PARSER,
1255
+ location=self._loc(key_tok),
1256
+ message=(
1257
+ "duplicate keyword argument "
1258
+ f"'{key_tok.value}'"
1259
+ ),
1260
+ hint=(
1261
+ f"Remove one '{key_tok.value}=' binding; "
1262
+ "a keyword argument may be specified only once."
1263
+ ),
1264
+ )
1265
+ ]
1266
+ )
1231
1267
  self._advance() # consume =
1232
1268
  val = self._parse_expression()
1233
1269
  kwargs[key_tok.value] = val
1270
+ call_arg_order.append(val)
1234
1271
  else:
1235
- args.append(self._parse_expression())
1272
+ val = self._parse_expression()
1273
+ args.append(val)
1274
+ call_arg_order.append(val)
1236
1275
 
1237
1276
  self._match(TokenType.COMMA)
1238
1277
 
1239
- return args, kwargs
1278
+ return args, kwargs, call_arg_order
1240
1279
 
1241
1280
  # -- Primary expressions --
1242
1281
 
@@ -229,7 +229,7 @@ _math("round",
229
229
  _sig([("x", F)], ret=I), # round(x) → int
230
230
  _sig([("x", F), ("precision", I)])) # round(x, n) → float
231
231
  _math("round_to_mintick", _sig([("x", F)]))
232
- _math("sign", _sig([("x", F)], ret=I))
232
+ _math("sign", _sig([("x", F)], ret=F))
233
233
  _math("max",
234
234
  _sig([("x", F), ("y", F)]), # 2-arg
235
235
  _sig([("x", F), ("y", F), ("z", F)]), # 3-arg
@@ -307,13 +307,13 @@ _strat("exit", _sig([
307
307
  _strat("close", _sig([
308
308
  ("id", S), ("comment", S, None),
309
309
  ("qty", F, None), ("qty_percent", F, None),
310
- ("alert_message", S, None), ("disable_alert", B, None),
311
- ("immediately", B, False),
310
+ ("alert_message", S, None), ("immediately", B, False),
311
+ ("disable_alert", B, None),
312
312
  ], ret=VOID))
313
313
 
314
314
  _strat("close_all", _sig([
315
315
  ("comment", S, None), ("alert_message", S, None),
316
- ("disable_alert", B, None), ("immediately", B, False),
316
+ ("immediately", B, False), ("disable_alert", B, None),
317
317
  ], ret=VOID))
318
318
 
319
319
  _strat("cancel", _sig([("id", S)], ret=VOID))