pine2pyne 0.2.4__py3-none-any.whl

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.
Files changed (75) hide show
  1. pine2pyne/__init__.py +84 -0
  2. pine2pyne/__main__.py +12 -0
  3. pine2pyne/ast_nodes.py +405 -0
  4. pine2pyne/ast_walk.py +93 -0
  5. pine2pyne/block_value_lowering.py +694 -0
  6. pine2pyne/bool_builtins.py +42 -0
  7. pine2pyne/bool_value_typing.py +580 -0
  8. pine2pyne/cli.py +247 -0
  9. pine2pyne/codegen.py +886 -0
  10. pine2pyne/compat/__init__.py +19 -0
  11. pine2pyne/compat/__main__.py +6 -0
  12. pine2pyne/compat/cli.py +114 -0
  13. pine2pyne/compat/comparison.py +105 -0
  14. pine2pyne/compat/corpus.py +73 -0
  15. pine2pyne/compat/report.py +95 -0
  16. pine2pyne/compat/runner.py +63 -0
  17. pine2pyne/condition_typing.py +260 -0
  18. pine2pyne/conditional_history_calls.py +96 -0
  19. pine2pyne/core/__init__.py +26 -0
  20. pine2pyne/core/contracts.py +125 -0
  21. pine2pyne/di.py +173 -0
  22. pine2pyne/emit/__init__.py +5 -0
  23. pine2pyne/emit/legacy.py +14 -0
  24. pine2pyne/emit/pynecomp/__init__.py +20 -0
  25. pine2pyne/emit/pynecomp/banner.py +30 -0
  26. pine2pyne/emit/pynecomp/blank_lines.py +69 -0
  27. pine2pyne/emit/pynecomp/emitter.py +61 -0
  28. pine2pyne/emit/pynecomp/imports.py +65 -0
  29. pine2pyne/emit/pynecomp/passes/__init__.py +84 -0
  30. pine2pyne/emit/pynecomp/passes/annotations.py +422 -0
  31. pine2pyne/emit/pynecomp/passes/base.py +69 -0
  32. pine2pyne/emit/pynecomp/passes/builtins.py +169 -0
  33. pine2pyne/emit/pynecomp/passes/hoisting.py +205 -0
  34. pine2pyne/emit/pynecomp/passes/imports.py +112 -0
  35. pine2pyne/emit/pynecomp/passes/legacy_colors.py +101 -0
  36. pine2pyne/emit/pynecomp/passes/library.py +205 -0
  37. pine2pyne/emit/pynecomp/passes/rewrites.py +215 -0
  38. pine2pyne/emit/pynecomp/printer.py +288 -0
  39. pine2pyne/emit/pynecomp/unparser.py +186 -0
  40. pine2pyne/emitted_names.py +152 -0
  41. pine2pyne/errors.py +69 -0
  42. pine2pyne/for_loop_end_bound.py +172 -0
  43. pine2pyne/grouped_history_lowering.py +310 -0
  44. pine2pyne/import_resolver.py +469 -0
  45. pine2pyne/input_hoisting.py +195 -0
  46. pine2pyne/legacy_function_names.py +74 -0
  47. pine2pyne/legacy_sessions.py +68 -0
  48. pine2pyne/lexer.py +630 -0
  49. pine2pyne/library_collision.py +434 -0
  50. pine2pyne/namespace_members.py +247 -0
  51. pine2pyne/node_ids.py +235 -0
  52. pine2pyne/once_lowering.py +75 -0
  53. pine2pyne/overload_resolution.py +475 -0
  54. pine2pyne/parse_repair.py +398 -0
  55. pine2pyne/parser.py +1873 -0
  56. pine2pyne/pine_builtins.py +394 -0
  57. pine2pyne/pine_semantics.py +604 -0
  58. pine2pyne/pipeline/__init__.py +18 -0
  59. pine2pyne/pipeline/service.py +70 -0
  60. pine2pyne/pipeline/stages.py +73 -0
  61. pine2pyne/scope_renamer.py +965 -0
  62. pine2pyne/strategy_when_lowering.py +101 -0
  63. pine2pyne/strict_boolean.py +101 -0
  64. pine2pyne/symbol_table.py +166 -0
  65. pine2pyne/tokens.py +143 -0
  66. pine2pyne/transformer.py +2510 -0
  67. pine2pyne/type_inference.py +422 -0
  68. pine2pyne/udt_field_defaults.py +149 -0
  69. pine2pyne/value_blocks.py +357 -0
  70. pine2pyne-0.2.4.dist-info/METADATA +99 -0
  71. pine2pyne-0.2.4.dist-info/RECORD +75 -0
  72. pine2pyne-0.2.4.dist-info/WHEEL +5 -0
  73. pine2pyne-0.2.4.dist-info/entry_points.txt +3 -0
  74. pine2pyne-0.2.4.dist-info/licenses/LICENSE +201 -0
  75. pine2pyne-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2510 @@
1
+ """
2
+ AST Transformer: Pine Script AST → PyneCore-compatible AST.
3
+
4
+ Implements all 54 transformation rules from the transpiler specification.
5
+ """
6
+ import dataclasses
7
+ import re
8
+ from typing import List, Dict, Any, Union, Optional
9
+ from .ast_nodes import *
10
+ from .symbol_table import SymbolTable, Symbol, VariableKind
11
+ from .type_inference import TypeInference, receiver_path
12
+ from .ast_walk import walk
13
+ from .block_value_lowering import bool_is_never_na, keeps_three_state_bool, lower_block_values
14
+ from .bool_value_typing import has_untaken_bool_switch, reads_na_of_bool, stamp_bool_values
15
+ from .condition_typing import ConditionTyping
16
+ from .grouped_history_lowering import lower_grouped_history
17
+ from .for_loop_end_bound import mark_dynamic_end_bounds
18
+ from .emitted_names import EMITTED_HELPER_NAMES, free_spelling, identifier_names
19
+ from .scope_renamer import collect_names, fresh_name, rename_shadowing_locals
20
+ from .strict_boolean import evaluates_and_or_strictly
21
+ from .overload_resolution import OverloadArgumentCoercion, mark_overloaded_declarations
22
+ from .udt_field_defaults import constant_heads, refuse_invalid_defaults
23
+ from .import_resolver import ImportResolver
24
+ from .pine_builtins import (
25
+ get_function_name, get_module_name, get_type_name,
26
+ get_plot_style_remap, needs_plot_style_remap, is_series_variable,
27
+ is_input_function, TYPE_RENAMES, PYNECORE_LIB_MODULES, DRAWING_ALL_VARIABLES, DRAWING_CAST_FUNCTIONS,
28
+ method_namespaces, bind_input_positional_args, SERIES_VARIABLES,
29
+ )
30
+
31
+ # Known module names - used to guard against transforming module.function() calls
32
+ # as if they were chained member access (e.g. array.size != obj.size)
33
+ KNOWN_MODULES = PYNECORE_LIB_MODULES | {
34
+ 'bar_index', 'close', 'open', 'high', 'low', 'volume', 'time',
35
+ 'hl2', 'hlc3', 'ohlc4', 'hlcc4',
36
+ 'dayofmonth', 'dayofweek', 'hour', 'minute', 'month', 'second',
37
+ 'weekofyear', 'year', 'timenow',
38
+ 'adjustment', 'earnings', 'dividends', 'splits',
39
+ 'order', 'str',
40
+ }
41
+
42
+ RANGE_HELPER = 'pine_range' # the runtime's fixed-bound loop helper (card #94)
43
+
44
+ # The Python type hints of a Pine map (`map<K, V>` -> `dict[K, V]`): `for [k, v]`
45
+ # over one reads its key/value pairs, not index/element (card #17)
46
+ MAP_TYPE_PREFIXES = ('dict',)
47
+ # The type hints of every collection a for-in can iterate: array, matrix, map
48
+ COLLECTION_TYPE_PREFIXES = ('list', 'Matrix') + MAP_TYPE_PREFIXES
49
+
50
+ # pynecore's runtime method dispatcher: `method_call('m', receiver, ...)` runs the
51
+ # namespace function of the receiver's class (card #8)
52
+ RUNTIME_METHOD_DISPATCH = 'method_call'
53
+ # array.new<T>(...) element types with a dedicated pynecore factory
54
+ ARRAY_FACTORIES = {
55
+ 'string': 'array.new_string',
56
+ 'int': 'array.new_int',
57
+ 'float': 'array.new_float',
58
+ 'bool': 'array.new_bool',
59
+ 'color': 'array.new_color',
60
+ 'line': 'array.new_line',
61
+ 'label': 'array.new_label',
62
+ 'box': 'array.new_box',
63
+ 'table': 'array.new_table',
64
+ 'linefill': 'array.new_linefill', # PyneComp writes `array.new_linefill()` (probe fp7)
65
+ }
66
+ ARRAY_FACTORY_ELEMENT_TYPES = frozenset(ARRAY_FACTORIES)
67
+ # Built-in object types without a factory: array.new<T>(size, value) goes through array.new (card #81)
68
+ BUILTIN_OBJECT_ELEMENT_TYPES = frozenset({'chart.point', 'polyline'})
69
+ # How a method call reaches a user method (Transformer._user_method_route)
70
+ USER_METHOD = 'user method'
71
+ RUNTIME_CHOICE = 'user method or builtin, by the receiver class'
72
+ from .errors import TransformerError, UnsupportedFeatureError
73
+ from .value_blocks import lower_value_blocks
74
+
75
+
76
+ # Builtins whose first parameter is a condition -> its keyword name
77
+ _CONDITION_PARAMETERS = {'ta.valuewhen': 'condition', 'ta.barssince': 'condition', 'alertcondition': 'condition'}
78
+
79
+ # Pine type casts of na -> the Python type of the typed na, NA(T)
80
+ _NA_CAST_TYPES = {
81
+ 'int': 'int', 'float': 'float', 'bool': 'bool', 'string': 'str', 'color': 'Color',
82
+ }
83
+
84
+
85
+ def _spelled_type(type_name: Optional[str]) -> str:
86
+ """A Pine or Python type in one spelling: `array<float>` and `list[float]` are the same."""
87
+ return convert_pine_generic_to_python(type_name).replace(' ', '') if type_name else ''
88
+
89
+
90
+ class PyneTransformedScript:
91
+ """Holds the transformed script ready for code generation."""
92
+
93
+ def __init__(self):
94
+ self.imports: List[str] = []
95
+ self.type_declarations: List[TypeDecl] = [] # Custom type definitions
96
+ self.enum_declarations: List[EnumDecl] = [] # Enum definitions
97
+ self.helper_functions: List[FuncDecl] = []
98
+ self.main_decorator: Optional[Union[IndicatorDecl, StrategyDecl]] = None
99
+ self.main_params: List[Parameter] = []
100
+ self.main_body: List[Statement] = []
101
+ self.global_vars: List[Union[VarDecl, Assignment]] = []
102
+ self.module_constants: List[Assignment] = [] # Pre-main literal constants
103
+ self.strict_boolean_operators = False # Pine v5 and earlier: and/or evaluate both operands (card #65)
104
+ self.bool_is_never_na = True # False for Pine v5 and earlier, where a UDT bool field without a default is na (card #76)
105
+ self.shadowed_namespaces: Dict[str, str] = {} # built-in namespace alias of a library -> its module name
106
+ self.reads_na_of_bool = False # Pine v5: a named bool is read with history or by na() (PyneComp's na_bool)
107
+ self.pine_version = 6 # the script's //@version
108
+
109
+
110
+ def convert_pine_generic_to_python(pine_type: str) -> str:
111
+ """
112
+ Convert Pine Script generic types to Python type hints.
113
+
114
+ Examples:
115
+ map<string, float> -> dict[str, float]
116
+ array<int> -> list[int]
117
+ """
118
+ # Check if it contains generic syntax
119
+ if '<' not in pine_type or '>' not in pine_type:
120
+ return get_type_name(pine_type)
121
+
122
+ # Extract base type and parameters
123
+ if '<' in pine_type:
124
+ base_type = pine_type[:pine_type.index('<')]
125
+ params_str = pine_type[pine_type.index('<')+1:pine_type.rindex('>')]
126
+ params = [p.strip() for p in params_str.split(',')]
127
+
128
+ # Convert base type
129
+ if base_type == 'map':
130
+ base_type = 'dict'
131
+ elif base_type == 'array':
132
+ base_type = 'list'
133
+ else:
134
+ # For other types like matrix, line, etc., use type name mapping
135
+ base_type = get_type_name(base_type)
136
+
137
+ # Convert parameter types
138
+ converted_params = [get_type_name(p) for p in params]
139
+
140
+ return f"{base_type}[{', '.join(converted_params)}]"
141
+
142
+ return get_type_name(pine_type)
143
+
144
+
145
+ class Transformer:
146
+ """Transforms Pine Script AST to PyneCore-compatible form.
147
+
148
+ One instance transforms ONE script (card #83): inputs, symbols, import state and method
149
+ maps live on the instance, so a second transform() raises TransformerError. Build a fresh
150
+ Transformer per script, as transpile() does.
151
+ """
152
+
153
+ def __init__(self):
154
+ self._transform_started = False # set by transform() before any work; see transform()
155
+ self.symbol_table = SymbolTable()
156
+ self.type_inference = TypeInference(self.symbol_table)
157
+ self.import_resolver = ImportResolver()
158
+ self.input_declarations: List[InputDecl] = []
159
+ self.current_function_params: Dict[str, str] = {} # Track current function parameters
160
+ self.current_function_shadowed_names: set = set() # Untyped parameters that also name a global
161
+ self.bool_is_never_na = True # Pine v6 and later; set per script by transform()
162
+ self.pine_version = 6 # set per script by transform()
163
+ self.three_state_bool = False # Pine v4 and v5: the decorator sets na_bool; set per script by transform()
164
+ self.type_inference.current_scope = lambda: (self.current_function_params, self.current_function_shadowed_names)
165
+ # Each @method function name -> the Python types of its `this` parameter
166
+ self.method_functions: Dict[str, set] = {}
167
+ self._renamed_functions: Dict[str, str] = {} # Pine name -> sanitized Python name
168
+ self._pine_keywords: Dict[str, Dict[str, str]] = {} # set by transform()
169
+ # A switch-subject temporary avoids every Pine name of the script; the
170
+ # names are collected on first need (card #43)
171
+ self._names_source: Optional[Script] = None
172
+ self._object_element_types: Optional[set] = None # array.new<T> element types (card #81)
173
+ self._used_names: Optional[set] = None
174
+ self._range_helper: Optional[str] = None # the name pine_range is imported under (card #94)
175
+ # Identifiers of the script and the names the module defines itself: what a generated
176
+ # spelling (the tuple temporary, a hidden source parameter) must not take (card #77)
177
+ self._identifier_names: Optional[set] = None
178
+ # float(...) for a float-typed argument where int and float overloads meet (card #39)
179
+ self._overload_arguments = OverloadArgumentCoercion(
180
+ Script(), self._visit_ast_nodes, self._receiver_type, self.symbol_table.udt_field_types)
181
+
182
+ # PRIORITY 1 FIX: Track Series variables in user-defined functions
183
+ self.in_user_function = False # Are we inside a user-defined function?
184
+ self.series_variables: set = set() # Variables that need to be Series objects
185
+ self.function_local_vars: set = set() # All local variables in current function
186
+ # Names the scope being emitted (main, or the current function) reads with `[n]` (card #48)
187
+ self.names_read_with_history: set = set()
188
+
189
+ def _visit_ast_nodes(self, nodes: list, visitor_fn) -> None:
190
+ """Call visitor_fn on every AST node, parents first. The shared walk also
191
+ enters else-if arms and switch cases, which are tuples (card #44)."""
192
+ for node in walk(nodes):
193
+ visitor_fn(node)
194
+
195
+ def _function_shadowed_names(self, func: FuncDecl) -> set:
196
+ """Untyped parameters, and locals, that also name a global.
197
+
198
+ The transformer types such a name from the GLOBAL symbol, so a field path
199
+ on it must not be typed from there. A function local keeps the name of a global
200
+ it hides (PyneComp does not rename it): it is typed from its own value only.
201
+ """
202
+ names = {param.name for param in func.params
203
+ if not param.type_hint and self.symbol_table.lookup_global(param.name)}
204
+ body = func.body if isinstance(func.body, list) else []
205
+ for node in walk(body):
206
+ if isinstance(node, Assignment):
207
+ bound = node.target.names if isinstance(node.target, TupleDestructure) else [node.target]
208
+ elif isinstance(node, (VarDecl, VaripDecl)):
209
+ bound = [node.name]
210
+ elif isinstance(node, ForLoop):
211
+ bound = [node.var]
212
+ elif isinstance(node, ForInLoop):
213
+ bound = list(node.vars)
214
+ else:
215
+ continue
216
+ names.update(name for name in bound if isinstance(name, str) and self.symbol_table.lookup_global(name))
217
+ return names
218
+
219
+ def _collect_reassignment_vars(self, script: Script) -> set:
220
+ """Collect variable names that are reassigned with := operator."""
221
+ reassign_vars = set()
222
+
223
+ def visitor(node):
224
+ if isinstance(node, Reassignment):
225
+ reassign_vars.add(node.target)
226
+
227
+ self._visit_ast_nodes(list(script.declarations) + list(script.body), visitor)
228
+ return reassign_vars
229
+
230
+ def _find_series_variables_in_function(self, func_body: List[Statement]) -> set:
231
+ """Find variables in a function that need to be Series objects.
232
+ A variable needs to be Series if it's reassigned with := or indexed with [n].
233
+ """
234
+ series_vars = set()
235
+
236
+ def visitor(node):
237
+ if isinstance(node, Reassignment):
238
+ series_vars.add(node.target)
239
+ if isinstance(node, IndexAccess):
240
+ if isinstance(node.object, Identifier):
241
+ series_vars.add(node.object.name)
242
+
243
+ self._visit_ast_nodes(func_body, visitor)
244
+ return series_vars
245
+
246
+ def transform(self, script: Script) -> PyneTransformedScript:
247
+ """Transform entire script. Single use: a second call raises (card #83)."""
248
+ if self._transform_started:
249
+ raise TransformerError(
250
+ 'a Transformer transforms one script and keeps its state; create a fresh Transformer '
251
+ 'for the next script (pine2pyne.transpile() does)')
252
+ self._transform_started = True # before any work, so a failed transform consumes the instance too
253
+ output = PyneTransformedScript()
254
+ output.strict_boolean_operators = evaluates_and_or_strictly(script)
255
+ version_text = getattr(script.version, 'version', '') if script.version else ''
256
+ self.pine_version = int(version_text) if str(version_text).isdigit() else 6
257
+ output.bool_is_never_na = bool_is_never_na(script)
258
+
259
+ # A Pine local that shadows an outer name gets its own name (card #38),
260
+ # so every stage below sees one binding per name
261
+ # Pine v6: an untaken if/switch of bool type is false, not na (card #50)
262
+ self.bool_is_never_na = bool_is_never_na(script)
263
+ self.three_state_bool = keeps_three_state_bool(script)
264
+ # A binding named like a pynecore.lib name the script also uses gets its
265
+ # own name; a namespace under an input or export that keeps it gets an alias (card #9)
266
+ # if/switch values become statements around __block_result__ temporaries (PyneComp)
267
+ # Pine v5: a switch without a default that gives a bool may be na (before the switches are lowered)
268
+ untaken_bool_switch = self.pine_version == 5 and has_untaken_bool_switch(script)
269
+ script = lower_value_blocks(script)
270
+ renamed = rename_shadowing_locals(script)
271
+ script = renamed.script
272
+ # Pine v5: PyneComp marks `na_bool=True` only where a named bool is read with history or by na()
273
+ output.pine_version = self.pine_version
274
+ output.reads_na_of_bool = (untaken_bool_switch or reads_na_of_bool(script)) if self.pine_version >= 5 else False
275
+ # which values PyneComp reads as numbers in a condition (cast_bool)
276
+ self._condition_typing = ConditionTyping(script)
277
+ self.import_resolver.aliased_modules = dict(renamed.namespace_aliases)
278
+ # user function or method name -> {emitted parameter name: its Pine name}:
279
+ # a builtin method of the same name is called with the Pine keyword (card #17)
280
+ self._pine_keywords = {name: {emitted: pine for pine, emitted in renames.items()}
281
+ for name, renames in renamed.parameter_names.items()}
282
+ # Pine v6: a for loop whose end may change in its body re-reads it on
283
+ # every pass; marked before the lowering, which refuses a history site there (card #82)
284
+ script = mark_dynamic_end_bounds(script)
285
+ # A history index on an expression reads a named local (card #32). It
286
+ # runs first: a branch it turns into a block is lowered by the next pass
287
+ # PyneComp reads the history of an expression with inline_series(expr, n): no lowering
288
+ # (`_transform_index_access`); the grouped-history lowering of card #32 is not run
289
+ # Pine v6: the bool type of every if/switch/loop value and function,
290
+ # decided once before the lowering rewrites them (card #56)
291
+ if self.bool_is_never_na:
292
+ script = stamp_bool_values(script)
293
+ # A multi-statement branch of an if/switch used as a value becomes
294
+ # statements that assign the value (card #41)
295
+ script = lower_block_values(script)
296
+ self._names_source, self._used_names, self._range_helper = script, None, None
297
+ self._object_element_types = None # array.new<T> element types, read on first use (card #81)
298
+ self._identifier_names = None
299
+
300
+ # Build symbol table
301
+ self._build_symbol_table(script)
302
+
303
+ # Infer types
304
+ self.type_inference.infer_types(script)
305
+ self._overload_arguments = OverloadArgumentCoercion(
306
+ script, self._visit_ast_nodes, self._receiver_type, self.symbol_table.udt_field_types)
307
+
308
+ # (Removed: _history_ref_vars replaced by symbol.is_indexed from type inference)
309
+
310
+ # Collect variables that use := (reassignment)
311
+ self._reassignment_vars = self._collect_reassignment_vars(script)
312
+ self.names_read_with_history = self._main_scope_history_reads(script)
313
+
314
+ # Analyze imports
315
+ self.import_resolver.analyze(script)
316
+
317
+ # Transform script declaration (indicator/strategy)
318
+ if script.script_decl:
319
+ output.main_decorator = self._transform_script_declaration(script.script_decl)
320
+
321
+ # Extract input declarations to main() parameters
322
+ self._extract_input_declarations(script)
323
+ output.main_params = self._create_main_parameters()
324
+
325
+ # Transform type and enum declarations - stay at module level
326
+ heads = constant_heads(script)
327
+ for decl in script.declarations:
328
+ if isinstance(decl, TypeDecl):
329
+ refuse_invalid_defaults(decl, heads) # only literals and built-ins (card #17)
330
+ output.type_declarations.append(decl) # Keep as-is for codegen
331
+ elif isinstance(decl, EnumDecl):
332
+ output.enum_declarations.append(decl) # Keep as-is for codegen
333
+
334
+ # Identify body assignments that must be module-level constants.
335
+ # In Pine Script, declarations before the first function call (like
336
+ # `const string G_ENTRY = '...'` or `DEFAULT_PYRAMIDING = 6`) can be
337
+ # referenced by input.* group= params and strategy() kwargs, which
338
+ # become main() parameter defaults evaluated at function definition time.
339
+ # These must be emitted at module level, not inside main().
340
+ module_const_names = self._collect_module_constant_names(script)
341
+
342
+ # Merge all non-type, non-input declarations with body statements
343
+ # sorted by source line number to preserve original Pine Script ordering
344
+ all_items: List[tuple] = []
345
+ for decl in script.declarations:
346
+ if isinstance(decl, (VarDecl, VaripDecl)):
347
+ all_items.append(('var', decl))
348
+ elif isinstance(decl, FuncDecl):
349
+ all_items.append(('func', decl))
350
+ # TypeDecl and InputDecl are handled separately above
351
+
352
+ for stmt in script.body:
353
+ all_items.append(('body', stmt))
354
+
355
+ # Sort by line number to preserve source order
356
+ all_items.sort(key=lambda x: x[1].line)
357
+
358
+ # Process all items in source order into main_body
359
+ # Propagate source line numbers for blank line detection in codegen
360
+ for item_type, item in all_items:
361
+ source_line = item.line
362
+ if item_type == 'var':
363
+ # Check if this var declaration should be a module-level constant
364
+ # (module_const_names holds only names whose value may be hoisted)
365
+ if isinstance(item, (VarDecl, VaripDecl)) and item.name in module_const_names:
366
+ # Emit as a plain module-level assignment with only its declared
367
+ # type: pynecore allows a Persistent or Series only inside a function
368
+ transformed = Assignment(
369
+ target=item.name, value=self._transform_expression(item.value),
370
+ type_hint=convert_pine_generic_to_python(item.type_hint) if item.type_hint else None)
371
+ transformed.line = source_line
372
+ output.module_constants.append(transformed)
373
+ continue
374
+ result = self._transform_var_declaration(item)
375
+ result.line = source_line
376
+ output.main_body.append(result)
377
+ elif item_type == 'func':
378
+ result = self._transform_function(item)
379
+ result.line = source_line
380
+ output.main_body.append(result)
381
+ else: # body
382
+ # Check if this is a literal assignment that should be module-level
383
+ if (isinstance(item, Assignment) and
384
+ isinstance(item.target, str) and
385
+ item.target in module_const_names):
386
+ # Emit as module-level constant instead of inside main()
387
+ transformed = self._transform_statement(item)
388
+ if transformed and not isinstance(transformed, list):
389
+ transformed.line = source_line
390
+ output.module_constants.append(transformed)
391
+ continue
392
+
393
+ transformed = self._transform_statement(item)
394
+ if transformed:
395
+ if isinstance(transformed, list):
396
+ for t in transformed:
397
+ t.line = source_line
398
+ output.main_body.extend(transformed)
399
+ else:
400
+ transformed.line = source_line
401
+ output.main_body.append(transformed)
402
+
403
+ # Same-name declarations become pynecore @overload implementations (card #39)
404
+ output.main_body, self.import_resolver.uses_overload = mark_overloaded_declarations(
405
+ output.main_body, {decl.name for decl in output.enum_declarations},
406
+ self.type_inference.declared_type_name)
407
+
408
+ # Analyze transformed AST for type names in type hints (Matrix, Line, etc.)
409
+ self._analyze_transformed_types(output)
410
+
411
+ # Generate imports
412
+ output.imports = self.import_resolver.generate_imports()
413
+ output.shadowed_namespaces = dict(self.import_resolver.shadowed_namespaces or {})
414
+
415
+ return output
416
+
417
+ def _get_func_name_from_call(self, call: FunctionCall) -> Optional[str]:
418
+ """Extract function name string from a FunctionCall node."""
419
+ if isinstance(call.func, str):
420
+ return call.func
421
+ elif isinstance(call.func, MemberAccess):
422
+ if isinstance(call.func.object, str):
423
+ return f"{call.func.object}.{call.func.member}"
424
+ elif isinstance(call.func.object, Identifier):
425
+ return f"{call.func.object.name}.{call.func.member}"
426
+ return None
427
+
428
+ def _analyze_transformed_types(self, output: PyneTransformedScript) -> None:
429
+ """Analyze transformed AST to extract type names from type hints."""
430
+ import re
431
+
432
+ # Track if Series is actually used
433
+ series_used = False
434
+ persistent_used = False
435
+
436
+ def extract_types_from_hint(type_hint: str) -> None:
437
+ """Extract type names like Line, Matrix from type hints."""
438
+ nonlocal series_used, persistent_used
439
+
440
+ if not type_hint:
441
+ return
442
+
443
+ # Check for Series and Persistent usage
444
+ # A typeless na initialiser is annotated bare `Series` (card #10);
445
+ # `PersistentSeries` alone needs no `Series` import (card #48)
446
+ if re.search(r'\bSeries\b', type_hint):
447
+ series_used = True
448
+ if re.search(r'\bPersistent\b', type_hint):
449
+ persistent_used = True
450
+
451
+ # Match capital letter type names that might be drawing/structural types
452
+ for match in re.finditer(r'\b([A-Z][a-zA-Z]*)\b', type_hint):
453
+ type_name = match.group(1)
454
+ # Add to import resolver if it's a known type
455
+ self.import_resolver.add_drawing_type(type_name)
456
+
457
+ def scan_statement(stmt: Statement) -> None:
458
+ """Recursively scan a statement for type hints."""
459
+ # Handle lists (flattened statement lists from hoisting)
460
+ if isinstance(stmt, list):
461
+ for s in stmt:
462
+ scan_statement(s)
463
+ return
464
+ if stmt is None:
465
+ return
466
+
467
+ # Check if statement has type hint
468
+ if hasattr(stmt, 'type_hint') and stmt.type_hint:
469
+ extract_types_from_hint(stmt.type_hint)
470
+
471
+ # Recursively scan nested structures
472
+ if isinstance(stmt, FuncDecl) and hasattr(stmt, 'params'):
473
+ for param in stmt.params:
474
+ if hasattr(param, 'type_hint') and param.type_hint:
475
+ extract_types_from_hint(param.type_hint)
476
+ if hasattr(stmt, 'body') and isinstance(stmt.body, list):
477
+ for body_stmt in stmt.body:
478
+ scan_statement(body_stmt)
479
+
480
+ # Scan all compound statement bodies
481
+ elif isinstance(stmt, IfStatement):
482
+ for body_stmt in (stmt.body or []):
483
+ scan_statement(body_stmt)
484
+ for _, elif_body in (stmt.elseifs or []):
485
+ for body_stmt in elif_body:
486
+ scan_statement(body_stmt)
487
+ for body_stmt in (stmt.else_body or []):
488
+ scan_statement(body_stmt)
489
+ elif isinstance(stmt, (ForLoop, ForInLoop, WhileLoop)):
490
+ for body_stmt in (stmt.body or []):
491
+ scan_statement(body_stmt)
492
+
493
+ # Scan global vars for type hints (var/varip declarations)
494
+ for var in output.global_vars:
495
+ if hasattr(var, 'type_hint') and var.type_hint:
496
+ extract_types_from_hint(var.type_hint)
497
+
498
+ # Scan module constants and the main body for type hints
499
+ for stmt in output.module_constants + output.main_body:
500
+ scan_statement(stmt)
501
+
502
+ # Scan main parameters for type hints
503
+ for param in output.main_params:
504
+ if hasattr(param, 'type_hint') and param.type_hint:
505
+ extract_types_from_hint(param.type_hint)
506
+
507
+ # Update import resolver based on actual usage in transformed code
508
+ self.import_resolver.uses_series = series_used
509
+ self.import_resolver.uses_persistent = persistent_used
510
+
511
+ # ========================================================================
512
+ # Symbol table building
513
+ # ========================================================================
514
+
515
+ def _build_symbol_table(self, script: Script) -> None:
516
+ """Build symbol table from script declarations."""
517
+ # Record declared UDT field types first: typing a var below (e.g.
518
+ # `var b = t.bx`) and every later method call on a field need them.
519
+ for decl in script.declarations:
520
+ if isinstance(decl, TypeDecl):
521
+ self.symbol_table.udt_field_types[decl.name] = {
522
+ field_name: convert_pine_generic_to_python(field_type)
523
+ for field_name, field_type, *_ in decl.fields if field_type
524
+ }
525
+
526
+ # Register input declarations
527
+ for decl in script.declarations:
528
+ if isinstance(decl, InputDecl):
529
+ symbol = Symbol(
530
+ decl.name,
531
+ VariableKind.INPUT,
532
+ is_global=True
533
+ )
534
+ self.symbol_table.define(symbol)
535
+
536
+ # Register var/varip declarations
537
+ for decl in script.declarations:
538
+ if isinstance(decl, VarDecl):
539
+ type_hint = self._infer_type_from_declaration(decl)
540
+ symbol = Symbol(
541
+ decl.name,
542
+ VariableKind.VAR,
543
+ type_hint=type_hint,
544
+ is_global=True
545
+ )
546
+ self.symbol_table.define(symbol)
547
+ elif isinstance(decl, VaripDecl):
548
+ type_hint = self._infer_type_from_declaration(decl)
549
+ symbol = Symbol(
550
+ decl.name,
551
+ VariableKind.VARIP,
552
+ type_hint=type_hint,
553
+ is_global=True
554
+ )
555
+ self.symbol_table.define(symbol)
556
+
557
+ # Register type declarations (UDTs)
558
+ for decl in script.declarations:
559
+ if isinstance(decl, TypeDecl):
560
+ symbol = Symbol(
561
+ decl.name,
562
+ VariableKind.TYPE,
563
+ is_global=True
564
+ )
565
+ self.symbol_table.define(symbol)
566
+
567
+ # Register function declarations
568
+ for decl in script.declarations:
569
+ if isinstance(decl, FuncDecl):
570
+ symbol = Symbol(
571
+ decl.name,
572
+ VariableKind.FUNCTION,
573
+ is_global=True
574
+ )
575
+ self.symbol_table.define(symbol)
576
+
577
+ # Register assignments as variables (including nested scopes)
578
+ self._register_assignments_recursive(script.body)
579
+
580
+ def _register_assignments_recursive(self, stmts: List[Statement]) -> None:
581
+ """Recursively register assignments in the symbol table, including nested scopes."""
582
+ for stmt in stmts:
583
+ if isinstance(stmt, Assignment) and isinstance(stmt.target, str):
584
+ if not self.symbol_table.lookup_global(stmt.target):
585
+ if hasattr(stmt, 'type_hint') and stmt.type_hint:
586
+ type_hint = convert_pine_generic_to_python(stmt.type_hint)
587
+ else:
588
+ type_hint = self.type_inference.infer_type_hint(stmt.value)
589
+ symbol = Symbol(
590
+ stmt.target,
591
+ VariableKind.SERIES,
592
+ type_hint=type_hint,
593
+ is_global=True
594
+ )
595
+ symbol.is_series = True
596
+ self.symbol_table.define(symbol)
597
+ # Recurse into nested scopes
598
+ if isinstance(stmt, IfStatement):
599
+ self._register_assignments_recursive(stmt.body)
600
+ for _, elif_body in stmt.elseifs:
601
+ self._register_assignments_recursive(elif_body)
602
+ if stmt.else_body:
603
+ self._register_assignments_recursive(stmt.else_body)
604
+ elif isinstance(stmt, SwitchStatement):
605
+ for _, case_body in stmt.cases:
606
+ self._register_assignments_recursive(case_body)
607
+ if stmt.default:
608
+ self._register_assignments_recursive(stmt.default)
609
+ elif isinstance(stmt, (ForLoop, ForInLoop, WhileLoop)):
610
+ if isinstance(stmt.body, list):
611
+ self._register_assignments_recursive(stmt.body)
612
+
613
+ def _infer_type_from_declaration(self, decl: Union[VarDecl, VaripDecl]) -> str:
614
+ """Infer type from var/varip declaration."""
615
+ if decl.type_hint:
616
+ return convert_pine_generic_to_python(decl.type_hint)
617
+ return self.type_inference.infer_type_hint(decl.value)
618
+
619
+ # ========================================================================
620
+ # Module-level constant detection
621
+ # ========================================================================
622
+
623
+ def _collect_identifier_names(self, node: Any) -> set:
624
+ """Recursively collect all Identifier names referenced in an AST node."""
625
+ names = set()
626
+ if isinstance(node, Identifier):
627
+ names.add(node.name)
628
+ elif isinstance(node, (FunctionCall, MethodCall)):
629
+ for arg in getattr(node, 'args', []):
630
+ names.update(self._collect_identifier_names(arg))
631
+ for val in getattr(node, 'kwargs', {}).values():
632
+ names.update(self._collect_identifier_names(val))
633
+ elif isinstance(node, (BinaryOp,)):
634
+ names.update(self._collect_identifier_names(node.left))
635
+ names.update(self._collect_identifier_names(node.right))
636
+ elif isinstance(node, (UnaryOp,)):
637
+ names.update(self._collect_identifier_names(node.operand))
638
+ elif isinstance(node, (TernaryOp,)):
639
+ names.update(self._collect_identifier_names(node.condition))
640
+ names.update(self._collect_identifier_names(node.true_expr))
641
+ names.update(self._collect_identifier_names(node.false_expr))
642
+ elif isinstance(node, MemberAccess):
643
+ names.update(self._collect_identifier_names(node.object))
644
+ elif isinstance(node, IndexAccess):
645
+ names.update(self._collect_identifier_names(node.object))
646
+ names.update(self._collect_identifier_names(node.index))
647
+ elif isinstance(node, ArrayLiteral):
648
+ for element in node.elements:
649
+ names.update(self._collect_identifier_names(element))
650
+ return names
651
+
652
+ def _collect_module_constant_names(self, script: Script) -> set:
653
+ """Collect names of body assignments that must be module-level constants.
654
+
655
+ In Pine Script, literal assignments before the first function call
656
+ (e.g., `const string G_ENTRY = '...'`) can be referenced by input.*()
657
+ group= params and strategy/indicator kwargs. Since these become main()
658
+ parameter defaults (evaluated at definition time), the referenced
659
+ variables must exist at module level.
660
+ """
661
+ # Collect all identifier names referenced in input declarations
662
+ referenced_names = set()
663
+ for decl in script.declarations:
664
+ if isinstance(decl, InputDecl):
665
+ for arg in decl.args:
666
+ referenced_names.update(self._collect_identifier_names(arg))
667
+ for val in decl.kwargs.values():
668
+ referenced_names.update(self._collect_identifier_names(val))
669
+
670
+ # Find body assignments that are literal values AND referenced by inputs
671
+ module_const_names = set()
672
+ for stmt in script.body:
673
+ if (isinstance(stmt, Assignment) and
674
+ isinstance(stmt.target, str) and
675
+ isinstance(stmt.value, Literal) and
676
+ stmt.target in referenced_names):
677
+ module_const_names.add(stmt.target)
678
+
679
+ # Also check var/varip declarations with literal values referenced by inputs
680
+ # (e.g., `var string G_STRATEGY = 'Strategy Settings'` used in group= params)
681
+ for decl in script.declarations:
682
+ if (isinstance(decl, (VarDecl, VaripDecl)) and
683
+ isinstance(decl.value, Literal) and
684
+ decl.name in referenced_names):
685
+ module_const_names.add(decl.name)
686
+
687
+ # a top-level `const` declaration is a module constant (PyneComp, probes const1, const2)
688
+ module_const_names.update(stmt.target for stmt in script.body
689
+ if isinstance(stmt, Assignment) and stmt.is_const and isinstance(stmt.target, str)
690
+ and self._is_constant_expression(stmt.value))
691
+ # strategy/indicator arguments and input arguments, through every constant they are built from
692
+ return (module_const_names | self._declaration_constant_names(script)
693
+ | self._input_constant_names(script))
694
+
695
+ def _declaration_constant_names(self, script: Script) -> set:
696
+ """Names the script declaration's arguments read, with the constants they are built from.
697
+
698
+ The decorator runs at import, before main(), so each name in its arguments must be a
699
+ module constant: a top-level binding whose value holds only literals, constant
700
+ namespace members (`strategy.fixed`), enum or imported-library members, constant
701
+ functions and other such constants. Anything else is refused here, by name, instead
702
+ of failing or reading an unset value at import (card #55).
703
+ """
704
+ if not script.script_decl:
705
+ return set()
706
+ roots = {name for value in script.script_decl.kwargs.values() for name in self._read_names(value)}
707
+ return self._constant_closure(script, roots, script.script_decl.line,
708
+ 'script declaration argument', 'the declaration needs its value before the script runs',
709
+ unbound_roots_allowed=False)
710
+
711
+ def _input_constant_names(self, script: Script) -> set:
712
+ """Names the inputs' arguments read, with the constants they are built from (card #78).
713
+
714
+ An input call is a default of main()'s signature, evaluated when the module loads, so a
715
+ top-level constant it reads (`const int LENGTH = BASE * 2`) must be a module constant
716
+ too: the same rule as the declaration's. A name that is no top-level binding (`close`,
717
+ `syminfo.tickerid`, another input) is not read from a constant and is left alone.
718
+ """
719
+ needed = set()
720
+ bindings = self._top_level_bindings(script)
721
+ for decl in script.declarations:
722
+ if not isinstance(decl, InputDecl):
723
+ continue
724
+ roots = {name for argument in [*decl.args, *decl.kwargs.values()]
725
+ for name in self._read_names(argument)}
726
+ needed |= self._constant_closure(
727
+ script, {name for name in roots if name in bindings}, decl.line,
728
+ f"default of input '{decl.name}'", 'an input default is read when the module loads',
729
+ unbound_roots_allowed=True)
730
+ return needed
731
+
732
+ def _constant_closure(self, script: Script, roots: set, line: int, subject: str, reason: str,
733
+ unbound_roots_allowed: bool) -> set:
734
+ """The top-level constants `roots` are built from, each checked to be a constant.
735
+
736
+ A name reached through a constant's value must be a constant, a constant namespace
737
+ member or an enum or library member; a root need not be bound (`unbound_roots_allowed`).
738
+ """
739
+ bindings = self._top_level_bindings(script)
740
+ namespaces = {decl.name for decl in script.declarations if isinstance(decl, EnumDecl)}
741
+ namespaces.update(imported.alias for imported in script.imports if imported.alias)
742
+ pending = [(name, True) for name in sorted(roots)]
743
+ needed = set()
744
+ while pending:
745
+ name, is_root = pending.pop()
746
+ if name in needed or self._is_namespace_constant(name, namespaces):
747
+ continue
748
+ value = bindings.get(name)
749
+ if value is None and is_root and unbound_roots_allowed:
750
+ continue
751
+ if value is None or not self._is_constant_expression(value):
752
+ raise TransformerError(
753
+ f"{subject} uses '{name}', which is not a constant declared "
754
+ f"at the top level; {reason}",
755
+ line)
756
+ needed.add(name)
757
+ pending.extend((read, False) for read in sorted(self._read_names(value)))
758
+ return needed
759
+
760
+ def _top_level_bindings(self, script: Script) -> Dict[str, Any]:
761
+ """Each top-level `name = value` and var/varip declaration's value, by name."""
762
+ bindings = {decl.name: decl.value for decl in script.declarations
763
+ if isinstance(decl, (VarDecl, VaripDecl))}
764
+ bindings.update({stmt.target: stmt.value for stmt in script.body
765
+ if isinstance(stmt, Assignment) and isinstance(stmt.target, str)})
766
+ return bindings
767
+
768
+ def _read_names(self, node: Any) -> set:
769
+ """The names `node` reads, except the true/false/na literals."""
770
+ return self._collect_identifier_names(node) - {'true', 'false', 'na'}
771
+
772
+ def _is_namespace_constant(self, name: str, namespaces: set) -> bool:
773
+ """A member of a constant builtin namespace, of an enum, or of an imported library."""
774
+ from .pine_builtins import DECLARATION_CONSTANT_NAMESPACES
775
+ return '.' in name and (name.startswith(DECLARATION_CONSTANT_NAMESPACES)
776
+ or name.split('.', 1)[0] in namespaces)
777
+
778
+ def _is_constant_expression(self, node: Any) -> bool:
779
+ """Built only from literals, names, operators and constant functions (the caller checks names)."""
780
+ from .pine_builtins import (DECLARATION_CONSTANT_FUNCTIONS, DECLARATION_CONSTANT_FUNCTION_NAMESPACES,
781
+ DECLARATION_STATEFUL_FUNCTIONS)
782
+ if isinstance(node, (Literal, Identifier)):
783
+ return True
784
+ if isinstance(node, ArrayLiteral):
785
+ return all(self._is_constant_expression(element) for element in node.elements)
786
+ if isinstance(node, FunctionCall):
787
+ constant_function = isinstance(node.func, str) and (
788
+ node.func in DECLARATION_CONSTANT_FUNCTIONS
789
+ or (node.func.startswith(DECLARATION_CONSTANT_FUNCTION_NAMESPACES)
790
+ and node.func not in DECLARATION_STATEFUL_FUNCTIONS))
791
+ return constant_function and all(
792
+ self._is_constant_expression(argument) for argument in [*node.args, *node.kwargs.values()])
793
+ if isinstance(node, BinaryOp):
794
+ return self._is_constant_expression(node.left) and self._is_constant_expression(node.right)
795
+ if isinstance(node, UnaryOp):
796
+ return self._is_constant_expression(node.operand)
797
+ if isinstance(node, TernaryOp):
798
+ return all(self._is_constant_expression(part)
799
+ for part in (node.condition, node.true_expr, node.false_expr))
800
+ return False
801
+
802
+ # ========================================================================
803
+ # Top-level transformations
804
+ # ========================================================================
805
+
806
+ def _transform_script_declaration(self, decl: Union[IndicatorDecl, StrategyDecl, LibraryDecl]) -> Union[IndicatorDecl, StrategyDecl, LibraryDecl]:
807
+ """Transform indicator/strategy/library declaration (Rules 2-3, 17, 54)."""
808
+ # Transform kwargs: convert true/false to True/False (Rule 17, 54)
809
+ transformed_kwargs = {}
810
+ for key, value in decl.kwargs.items():
811
+ transformed_kwargs[key] = self._transform_decorator_value(value)
812
+ if self.three_state_bool:
813
+ # Pine v4 and v5: `bool b = na` is a real na (card #99, item 5; the runtime's NA(bool) is False without this)
814
+ transformed_kwargs['na_bool'] = True
815
+
816
+ return type(decl)(title=decl.title, kwargs=transformed_kwargs, line=decl.line,
817
+ positional_count=decl.positional_count, title_double_quoted=decl.title_double_quoted,
818
+ keyword_order=decl.keyword_order)
819
+
820
+ def _transform_decorator_value(self, value: Any) -> Any:
821
+ """Transform decorator keyword argument values (Rule 17, 54)."""
822
+ if isinstance(value, Literal):
823
+ if value.literal_type == 'bool':
824
+ # Convert true/false to True/False (Rule 17)
825
+ return value.value # Will be True or False in Python
826
+ return value
827
+ elif isinstance(value, Identifier):
828
+ # Handle true/false identifiers
829
+ if value.name == 'true':
830
+ return True
831
+ elif value.name == 'false':
832
+ return False
833
+ return value
834
+
835
+ def _extract_input_declarations(self, script: Script) -> None:
836
+ """Extract input declarations for main() parameters (Rules 8-9)."""
837
+ for decl in script.declarations:
838
+ if isinstance(decl, InputDecl):
839
+ self.input_declarations.append(decl)
840
+
841
+ def _create_main_parameters(self) -> List[Parameter]:
842
+ """Create main() function parameters from input declarations (Rule 8)."""
843
+ params = []
844
+ input_names = {input_decl.name for input_decl in self.input_declarations}
845
+
846
+ for input_decl in self.input_declarations:
847
+ args = list(input_decl.args)
848
+ kwargs = dict(input_decl.kwargs)
849
+
850
+ # Convert excess positional args to keyword args.
851
+ # Pine Script allows input.int(defval, title, minval, maxval, ...)
852
+ # but PyneCore requires minval=, maxval=, ... as keyword-only.
853
+ args, positional_keywords = bind_input_positional_args(input_decl.func, args)
854
+ # the positionals bound to names come first, in their order, then the source's keywords
855
+ kwargs = {**positional_keywords, **kwargs}
856
+ kwargs = self._input_keywords_without_input_reads(input_decl, kwargs, input_names)
857
+
858
+ func_call = FunctionCall(
859
+ func=input_decl.func,
860
+ args=args,
861
+ kwargs=kwargs
862
+ )
863
+
864
+ # A source input (input.source, or input() of a price) is a series: Series[float]
865
+ if self._is_source_input(input_decl.func, args, kwargs):
866
+ params.append(Parameter(name=input_decl.name, type_hint='Series[float]', default=func_call,
867
+ line=input_decl.line))
868
+ self.import_resolver.uses_series = True
869
+ continue
870
+
871
+ param = Parameter(
872
+ name=input_decl.name,
873
+ type_hint=self._input_parameter_annotation(input_decl.name, func_call),
874
+ default=func_call,
875
+ line=input_decl.line
876
+ )
877
+ params.append(param)
878
+
879
+ return params
880
+
881
+ @staticmethod
882
+ def _is_source_input(function: str, args: list, kwargs: dict) -> bool:
883
+ if function == 'input.source':
884
+ return True
885
+ if function != 'input':
886
+ return False
887
+ default = args[0] if args else kwargs.get('defval')
888
+ return isinstance(default, Identifier) and default.name in (
889
+ 'close', 'open', 'high', 'low', 'volume', 'hl2', 'hlc3', 'ohlc4', 'hlcc4')
890
+
891
+ @staticmethod
892
+ def _input_keywords_without_input_reads(input_decl: InputDecl, keywords: dict, input_names: set) -> dict:
893
+ """The input's keyword arguments (`keywords`, its positional ones bound by name), for a
894
+ main() parameter default, which the module evaluates before any input exists (card #89). Pine lets two arguments read another
895
+ input; the semantic pass refuses the rest. `active` (an input bool) only greys out the
896
+ field in TradingView, and pynecore never reads it: it is dropped when it reads an input.
897
+ input.source's `defval` (a series float) would make the default follow the other
898
+ input's setting, which a parameter default cannot: refused as unsupported."""
899
+ def reads_input(argument) -> Optional[str]:
900
+ return next((node.name for node in walk(argument)
901
+ if isinstance(node, Identifier) and node.name in input_names), None)
902
+
903
+ kwargs = dict(keywords)
904
+ if 'active' in kwargs and not isinstance(kwargs['active'], Literal):
905
+ # `active` only greys the field out in TradingView: PyneComp keeps a constant one only
906
+ del kwargs['active']
907
+ defval = input_decl.args[0] if input_decl.args else kwargs.get('defval')
908
+ read = reads_input(defval) if input_decl.func == 'input.source' else None
909
+ if read:
910
+ raise UnsupportedFeatureError(
911
+ f"the default of source input '{input_decl.name}' reads the input '{read}'; a default that "
912
+ f"follows another input's setting has no PyneCore equivalent", input_decl.line)
913
+ return kwargs
914
+
915
+ def _input_parameter_annotation(self, name: str, input_call: FunctionCall) -> Optional[str]:
916
+ """`Series[T]` for an input some scope reads with history (`length[1]`), else none.
917
+
918
+ An input is a main() parameter holding a plain value; the runtime keeps a
919
+ history only for a name annotated as a series. The parameter keeps its name,
920
+ which is the input's settings id (card #90)."""
921
+ if name not in self.names_read_with_history:
922
+ return None
923
+ element_type = self.type_inference.infer_type_hint(input_call)
924
+ return self._series_annotation(name, None if element_type == 'Any' else element_type)
925
+
926
+ # ========================================================================
927
+ # Function transformations
928
+ # ========================================================================
929
+
930
+ @staticmethod
931
+ def _history_reads(tree) -> set:
932
+ """Names read with history (`source[i]`) anywhere in `tree`, including
933
+ else-if arms, if/switch-expression branches and switch cases (card #44)."""
934
+ return {node.object.name for node in walk(tree)
935
+ if isinstance(node, IndexAccess) and isinstance(node.object, Identifier)}
936
+
937
+ def _main_scope_history_reads(self, script: Script) -> set:
938
+ """Names the main scope reads with history: its own `x[n]` sites, and a
939
+ function's sites on a name that is not its parameter, which is a global
940
+ (a function local that shadows a global was renamed, card #38)."""
941
+ names = set()
942
+ for item in list(script.declarations) + list(script.body):
943
+ if isinstance(item, FuncDecl):
944
+ names |= self._history_reads(item.body) - {param.name for param in item.params}
945
+ else:
946
+ names |= self._history_reads(item)
947
+ return names
948
+
949
+ def _transform_function(self, func: FuncDecl) -> FuncDecl:
950
+ """Transform function declaration (Rules 20-22, 46-48)."""
951
+ # Save current function params context
952
+ old_params = self.current_function_params
953
+ self.current_function_params = {}
954
+ # the parameters of this function without a declared type: PyneComp routes their methods at run time
955
+ old_untyped_parameters = getattr(self, '_untyped_parameters', frozenset())
956
+ self._untyped_parameters = frozenset(param.name for param in func.params if not param.type_hint)
957
+ old_shadowed_names = self.current_function_shadowed_names
958
+ self.current_function_shadowed_names = self._function_shadowed_names(func)
959
+ typing = getattr(self, '_condition_typing', None)
960
+ old_typing_scope = getattr(self, '_typing_scope', None)
961
+ self._typing_scope = func
962
+ if typing is not None:
963
+ typing.enter(func)
964
+
965
+ # PRIORITY 1 FIX: Save and set function context
966
+ old_in_function = self.in_user_function
967
+ old_series_vars = self.series_variables
968
+ old_local_vars = self.function_local_vars
969
+
970
+ self.in_user_function = True
971
+ self.series_variables = self._find_series_variables_in_function(
972
+ func.body if isinstance(func.body, list) else [func.body]
973
+ )
974
+ self.function_local_vars = set()
975
+
976
+ old_history_reads = self.names_read_with_history
977
+ self.names_read_with_history = self._history_reads(func.body)
978
+
979
+ # Find which parameters are indexed in the function body
980
+ param_names = {p.name for p in func.params}
981
+ indexed_params = self.names_read_with_history & param_names
982
+
983
+ # Transform parameters
984
+ # For methods, the first parameter has special syntax
985
+ transformed_params = []
986
+ for i, param in enumerate(func.params):
987
+ # Convert Pine generic types to Python types
988
+ if param.type_hint:
989
+ type_hint = convert_pine_generic_to_python(param.type_hint)
990
+ # Wrap in Series[T] if parameter is indexed with [n] history access.
991
+ # The runtime SeriesTransformer will create SeriesImpl and register it.
992
+ if param.name in indexed_params or param.is_series:
993
+ type_hint = f'Series[{type_hint}]'
994
+ else:
995
+ # If parameter is indexed and has no type hint, add bare Series
996
+ type_hint = 'Series' if param.name in indexed_params else None
997
+
998
+ # Track parameter types for method call transformation
999
+ if type_hint:
1000
+ self.current_function_params[param.name] = type_hint
1001
+
1002
+ default_value = self._transform_expression(param.default) if param.default else None
1003
+ if param.type_hint == 'bool' and isinstance(param.default, NaLiteral):
1004
+ default_value = Literal(value=False, literal_type='bool') # `bool b = na` is `b: bool = False`
1005
+ if default_value is None and any(earlier.default for earlier in func.params[:i]):
1006
+ # Python needs a default after a defaulted parameter: PyneComp gives it na
1007
+ default_value = Identifier(name='na')
1008
+
1009
+ transformed_params.append(Parameter(
1010
+ name=param.name,
1011
+ type_hint=type_hint,
1012
+ default=default_value
1013
+ ))
1014
+
1015
+ # Transform body
1016
+ if isinstance(func.body, Expression):
1017
+ # Single-line arrow function (Rule 21)
1018
+ transformed_body = ReturnStatement(expr=self._transform_expression(func.body))
1019
+ else:
1020
+ # Multi-line function (Rule 22)
1021
+ transformed_body = [self._transform_statement(stmt) for stmt in func.body]
1022
+ # The value of a Pine function is the value of its last statement (Rule 20);
1023
+ # this also flattens statement lists and drops None statements
1024
+ transformed_body = self._return_tail_value(transformed_body, untaken_is_false=func.value_is_bool)
1025
+
1026
+ # Restore previous params context
1027
+ self.current_function_params = old_params
1028
+ self._untyped_parameters = old_untyped_parameters
1029
+ self.current_function_shadowed_names = old_shadowed_names
1030
+
1031
+ # PRIORITY 1 FIX: Restore function context
1032
+ self._typing_scope = old_typing_scope
1033
+ if typing is not None:
1034
+ typing.enter(old_typing_scope)
1035
+ self.in_user_function = old_in_function
1036
+ self.series_variables = old_series_vars
1037
+ self.function_local_vars = old_local_vars
1038
+ self.names_read_with_history = old_history_reads
1039
+
1040
+ # Track method functions, with their `this` types, for call transformation
1041
+ if func.is_method:
1042
+ this_hint = func.params[0].type_hint if func.params else None
1043
+ this_type = convert_pine_generic_to_python(this_hint) if this_hint else None
1044
+ self.method_functions.setdefault(func.name, set()).add(this_type)
1045
+
1046
+ # Sanitize function name if it clashes with Python reserved words
1047
+ from .pine_builtins import sanitize_identifier
1048
+ sanitized_name = sanitize_identifier(func.name)
1049
+ if sanitized_name != func.name:
1050
+ self._renamed_functions[func.name] = sanitized_name
1051
+
1052
+ return FuncDecl(
1053
+ name=sanitized_name,
1054
+ params=transformed_params,
1055
+ body=transformed_body,
1056
+ is_method=func.is_method,
1057
+ is_export=func.is_export, # codegen emits `@export` for it (card #111)
1058
+ indexed_params=indexed_params,
1059
+ )
1060
+
1061
+ def _return_tail_value(self, body: List[Statement], is_function_body: bool = True,
1062
+ untaken_is_false: bool = False) -> List[Statement]:
1063
+ """Return a copy of a function body (or of one arm of its tail if) that returns its value.
1064
+
1065
+ An expression tail becomes ``return expr``; an assignment tail is followed by
1066
+ ``return target``. An if tail is rewritten arm by arm (if, every else-if, else),
1067
+ recursively, so the arm that runs returns its own value. An arm that yields no
1068
+ value (an untaken if without else, a loop) falls through to one ``return na``
1069
+ after the function's tail if, or ``return False`` when `untaken_is_false`
1070
+ (Pine v6: the function's value is of type bool, card #56). Pine has no early return, so returning from inside
1071
+ the LAST statement skips nothing that would have run after it.
1072
+ """
1073
+ statements = self._flatten_statements(body)
1074
+ if not statements:
1075
+ return statements
1076
+ last = statements[-1]
1077
+ if isinstance(last, ExpressionStatement):
1078
+ return statements[:-1] + [self._keep_source_line(last, ReturnStatement(expr=last.expr))]
1079
+ if isinstance(last, Assignment):
1080
+ return statements + [ReturnStatement(expr=self._assigned_value(last.target))]
1081
+ if not isinstance(last, IfStatement):
1082
+ return statements
1083
+ returning_if = self._keep_source_line(last, IfStatement(
1084
+ condition=last.condition,
1085
+ body=self._return_tail_value(last.body, is_function_body=False),
1086
+ elseifs=[(condition, self._return_tail_value(arm, is_function_body=False))
1087
+ for condition, arm in last.elseifs],
1088
+ else_body=(self._return_tail_value(last.else_body, is_function_body=False)
1089
+ if last.else_body else None),
1090
+ ))
1091
+ # No trailing `return na` when every arm returns: it could never run
1092
+ if not is_function_body or self._always_returns([returning_if]):
1093
+ return statements[:-1] + [returning_if]
1094
+ if untaken_is_false:
1095
+ return statements[:-1] + [returning_if, ReturnStatement(expr=Literal(value=False, literal_type='bool'))]
1096
+ # Pine's value for an untaken if, or for an arm without a value, is na
1097
+ self.import_resolver.lib_modules.add('na')
1098
+ return statements[:-1] + [returning_if, ReturnStatement(expr=Identifier(name='na'))]
1099
+
1100
+ @staticmethod
1101
+ def _assigned_value(target) -> Expression:
1102
+ """The value an assignment leaves behind: the variable, or the tuple of destructured names."""
1103
+ if isinstance(target, TupleDestructure):
1104
+ return ArrayLiteral(elements=[Identifier(name=name) for name in target.names])
1105
+ return Identifier(name=target) if isinstance(target, str) else target
1106
+
1107
+ @staticmethod
1108
+ def _always_returns(statements: List[Statement]) -> bool:
1109
+ """True when every path through the statements ends in a return."""
1110
+ if not statements:
1111
+ return False
1112
+ last = statements[-1]
1113
+ if isinstance(last, ReturnStatement):
1114
+ return True
1115
+ if isinstance(last, IfStatement) and last.else_body:
1116
+ arms = [last.body] + [arm for _, arm in last.elseifs] + [last.else_body]
1117
+ return all(Transformer._always_returns(arm) for arm in arms)
1118
+ return False
1119
+
1120
+ @staticmethod
1121
+ def _flatten_statements(statements: List[Statement]) -> List[Statement]:
1122
+ """Branch bodies keep statement lists (a split tuple, a loop used as a value) and dropped
1123
+ (None) statements; flatten them."""
1124
+ flat = []
1125
+ for statement in statements:
1126
+ if statement is None:
1127
+ continue
1128
+ if isinstance(statement, list):
1129
+ flat.extend(Transformer._flatten_statements(statement))
1130
+ else:
1131
+ flat.append(statement)
1132
+ return flat
1133
+
1134
+ # ========================================================================
1135
+ # Statement transformations
1136
+ # ========================================================================
1137
+
1138
+ def _transform_statement(self, stmt: Statement) -> Union[Statement, List[Statement], None]:
1139
+ """Transform statement."""
1140
+ if isinstance(stmt, Assignment):
1141
+ result = self._transform_assignment(stmt)
1142
+ elif isinstance(stmt, Reassignment):
1143
+ result = self._transform_reassignment(stmt)
1144
+ elif isinstance(stmt, (VarDecl, VaripDecl)):
1145
+ result = self._transform_var_declaration(stmt)
1146
+ elif isinstance(stmt, IfStatement):
1147
+ result = self._transform_if_statement(stmt)
1148
+ elif isinstance(stmt, ForLoop):
1149
+ result = self._transform_for_loop(stmt)
1150
+ elif isinstance(stmt, ForInLoop):
1151
+ result = self._transform_for_in_loop(stmt)
1152
+ elif isinstance(stmt, WhileLoop):
1153
+ result = self._transform_while_loop(stmt)
1154
+ elif isinstance(stmt, SwitchStatement):
1155
+ result = self._transform_switch_statement(stmt)
1156
+ elif isinstance(stmt, ExpressionStatement):
1157
+ # Skip stray declaration calls (indicator/strategy/library) in body
1158
+ if isinstance(stmt.expr, FunctionCall) and isinstance(stmt.expr.func, str):
1159
+ if stmt.expr.func in ('indicator', 'strategy', 'library'):
1160
+ return None
1161
+ result = ExpressionStatement(expr=self._transform_expression(stmt.expr))
1162
+ elif isinstance(stmt, (BreakStatement, ContinueStatement)):
1163
+ result = stmt
1164
+ else:
1165
+ result = stmt
1166
+ return self._keep_source_line(stmt, result)
1167
+
1168
+ @staticmethod
1169
+ def _keep_source_line(source: Statement, result):
1170
+ """A statement built for a nested Pine statement remembers its Pine line in
1171
+ `source_line`, so a layout printer can place blank lines where the source
1172
+ has them. `line` stays unset: refusals keep reporting the enclosing
1173
+ top-level line (card #40). Only the first statement of an expansion takes it."""
1174
+ line = getattr(source, 'line', 0) or getattr(source, 'source_line', 0)
1175
+ first = result[0] if isinstance(result, list) and result else result
1176
+ if line and first is not None and not isinstance(first, list) and not getattr(first, 'line', 0):
1177
+ try:
1178
+ first.source_line = line
1179
+ except AttributeError:
1180
+ pass
1181
+ return result
1182
+
1183
+ def _transform_assignment(self, stmt: Assignment) -> Union[Assignment, List[Statement]]:
1184
+ """Transform assignment (Rules 5-7)."""
1185
+ # `[a, b] = g()` stays one destructure, as PyneComp writes it; a name this scope reads with history is
1186
+ # declared first, bare: `p: Series` (probe tup1)
1187
+ if isinstance(stmt.target, TupleDestructure) and not getattr(self, '_declaring_tuple', False):
1188
+ series_names = [name for name in stmt.target.names if name in self.names_read_with_history]
1189
+ if series_names:
1190
+ self.import_resolver.uses_series = True
1191
+ self._declaring_tuple = True
1192
+ try:
1193
+ rest = self._transform_assignment(stmt)
1194
+ finally:
1195
+ self._declaring_tuple = False
1196
+ declarations = [RawCode(code=f'{name}: Series') for name in series_names]
1197
+ return declarations + (rest if isinstance(rest, list) else [rest])
1198
+
1199
+ # PRIORITY 1 FIX: Track first assignment to Series variables in functions
1200
+ is_first_assignment_to_series = False
1201
+ if self.in_user_function and isinstance(stmt.target, str):
1202
+ if stmt.target in self.series_variables and stmt.target not in self.function_local_vars:
1203
+ is_first_assignment_to_series = True
1204
+ self.function_local_vars.add(stmt.target)
1205
+
1206
+ # Determine type annotation first (we need it for na value transformation)
1207
+ type_hint = None
1208
+ converted_type = None
1209
+ if isinstance(stmt.target, str):
1210
+ symbol = self.symbol_table.lookup(stmt.target)
1211
+ if self.in_user_function and stmt.target in self.current_function_shadowed_names:
1212
+ symbol = None # a function's own local, not the global it hides
1213
+ # CRITICAL: In Pine Script, series is the default type qualifier
1214
+ if hasattr(stmt, 'type_hint') and stmt.type_hint:
1215
+ # Use the explicit type hint from parser (e.g., map<int, float> mapExample = na)
1216
+ converted_type = convert_pine_generic_to_python(stmt.type_hint)
1217
+ # If variable is used with historical reference [], wrap in Series[type]
1218
+ if symbol and symbol.is_indexed:
1219
+ type_hint = self._series_annotation(stmt.target, converted_type)
1220
+ else:
1221
+ type_hint = converted_type
1222
+ elif symbol:
1223
+ # No explicit type in Pine source — only infer for na value transformation
1224
+ # and var/varip persistence (the same rule as a var declaration)
1225
+ if symbol.kind in (VariableKind.VAR, VariableKind.VARIP):
1226
+ base_type = symbol.type_hint or self.type_inference.infer_type_hint(stmt.value)
1227
+ converted_type = base_type
1228
+ type_hint = self._series_annotation(
1229
+ stmt.target, self._series_element_type(base_type, stmt.value), persistent=True)
1230
+ elif isinstance(stmt.value, NaLiteral):
1231
+ # Need type for na() conversion even without explicit annotation
1232
+ base_type = symbol.type_hint or self.type_inference.infer_type_hint(stmt.value)
1233
+ converted_type = base_type
1234
+ if symbol.is_indexed:
1235
+ type_hint = self._series_annotation(
1236
+ stmt.target, self._series_element_type(base_type, stmt.value))
1237
+ elif isinstance(stmt.target, str) and stmt.target in getattr(self, '_reassignment_vars', set()) and symbol.is_indexed:
1238
+ # Variable uses both := (reassignment) and [] (historical reference)
1239
+ # WITHOUT 'var' keyword - this is a Series in Pine Script semantics
1240
+ base_type = self._series_element_type(
1241
+ symbol.type_hint or self.type_inference.infer_type_hint(stmt.value), stmt.value)
1242
+ converted_type = base_type
1243
+ type_hint = self._series_annotation(stmt.target, base_type)
1244
+ elif isinstance(stmt.target, str) and symbol.is_indexed:
1245
+ # Variable is used with [] - in Pine Script, series is the default type qualifier
1246
+ base_type = self._series_element_type(
1247
+ symbol.type_hint or self.type_inference.infer_type_hint(stmt.value), stmt.value)
1248
+ converted_type = base_type
1249
+ type_hint = self._series_annotation(stmt.target, base_type)
1250
+ # Otherwise: no type annotation for body assignments without explicit Pine types
1251
+
1252
+ # Transform value - special case for na with type hint
1253
+ if isinstance(stmt.value, NaLiteral) and converted_type:
1254
+ value = self._na_initialiser(converted_type)
1255
+ elif getattr(stmt, 'type_hint', None) == 'bool' and not getattr(stmt, 'is_reassignment', False):
1256
+ value = self._condition(stmt.value) # `bool b = ta.change(time('D'))` is cast (PyneComp)
1257
+ else:
1258
+ value = self._transform_expression(stmt.value)
1259
+
1260
+ # Suppress 'Any' type annotations - omit annotation when type is unknown
1261
+ if type_hint == 'Any':
1262
+ type_hint = None
1263
+
1264
+ # PRIORITY 1 FIX: For Series variables in functions, add Series type hint
1265
+ if is_first_assignment_to_series:
1266
+ base_type = self._series_element_type(
1267
+ converted_type or self.type_inference.infer_type_hint(stmt.value), stmt.value)
1268
+ type_hint = self._series_annotation(stmt.target, base_type)
1269
+
1270
+ # Record this local's type in the current-function scope map so later
1271
+ # method calls on it resolve type-aware, e.g. `thisT = m.transpose()`
1272
+ # (thisT: Matrix[int]) then `thisT.sort(...)` → matrix.sort(...). Function
1273
+ # locals are not in the global symbol table, so this is the only place
1274
+ # their type is known; the map is per-function (saved/reset around each
1275
+ # function), and body statements transform in source order so the type is
1276
+ # in place before the call. Only concrete types are recorded.
1277
+ if self.in_user_function and isinstance(stmt.target, str):
1278
+ # Prefer the concrete converted type; otherwise infer from the
1279
+ # TRANSFORMED value (e.g. m.transpose() is now matrix.transpose(m),
1280
+ # whose return type the inference knows), not the pre-transform AST.
1281
+ local_type = converted_type or self.type_inference.infer_type_hint(value)
1282
+ if (not local_type or local_type == 'Any') and isinstance(stmt.value, FunctionCall) \
1283
+ and isinstance(stmt.value.func, str) and stmt.value.func.endswith('.new') \
1284
+ and stmt.value.func[:-4] in self.symbol_table.udt_field_types:
1285
+ local_type = stmt.value.func[:-4] # `t = U.new(...)`: a U
1286
+ if local_type and local_type != 'Any':
1287
+ self.current_function_params[stmt.target] = local_type
1288
+
1289
+ return Assignment(target=stmt.target, value=value, type_hint=type_hint)
1290
+
1291
+ def _series_element_type(self, inferred_type: Optional[str], value: Expression) -> Optional[str]:
1292
+ """The element type of a Series annotation: the inferred type when it is
1293
+ known, else Color for a colour value (card #32), else None. A guessed
1294
+ float breaks a colour or a string held in an untyped variable (card #48)."""
1295
+ if inferred_type and inferred_type != 'Any':
1296
+ return inferred_type
1297
+ return 'Color' if self.type_inference.is_colour_value(value) else None
1298
+
1299
+ def _series_annotation(self, name: str, element_type: Optional[str],
1300
+ persistent: bool = False) -> Optional[str]:
1301
+ """The annotation of a name that keeps a history (card #48).
1302
+
1303
+ `Series[T]`, or for a var `PersistentSeries[T]`, which the runtime splits
1304
+ into `Persistent[T]` + `Series[T]` so that `x[n]` works on any var. The
1305
+ runtime drops a `Series[T]` half that is never indexed. An element type
1306
+ the inference cannot name (None) gives the bare form: the runtime types
1307
+ it from the values assigned, as for a typeless na (card #10). The
1308
+ runtime never drops a bare Series half, so the bare form is emitted only
1309
+ for a name this scope reads with history; any other name gets no Series
1310
+ half: a var is a bare `Persistent`, anything else has no annotation."""
1311
+ if not element_type and name not in self.names_read_with_history:
1312
+ return 'Persistent' if persistent else None
1313
+ if persistent:
1314
+ kind = 'PersistentSeries'
1315
+ self.import_resolver.uses_persistent_series = True
1316
+ else:
1317
+ kind = 'Series'
1318
+ self.import_resolver.uses_series = True
1319
+ return f"{kind}[{element_type}]" if element_type else kind
1320
+
1321
+ def _transform_reassignment(self, stmt: Reassignment) -> Assignment:
1322
+ """
1323
+ Transform reassignment := to = (Rule 11).
1324
+ """
1325
+ # Default behavior: regular assignment
1326
+ # PRIORITY 1 FIX: Series type hint will be added during assignment transformation
1327
+ return Assignment(
1328
+ target=stmt.target,
1329
+ value=self._transform_expression(stmt.value)
1330
+ )
1331
+
1332
+ def _transform_var_declaration(self, decl: Union[VarDecl, VaripDecl]) -> Assignment:
1333
+ """Transform var/varip declaration to a PersistentSeries assignment (Rule 5-6).
1334
+
1335
+ Every type, scalar or not, keeps a Series half, so `x[n]` reads the
1336
+ value (for an array, map, matrix, UDT or drawing: the reference) from n
1337
+ bars ago, as in Pine (card #48). The exception is a var of unknown type
1338
+ that no scope reads with history: a bare `Persistent` (`_series_annotation`).
1339
+ """
1340
+ base_type = decl.type_hint or self.type_inference.infer_type_hint(decl.value)
1341
+ # Convert generic types properly
1342
+ converted_type = convert_pine_generic_to_python(base_type)
1343
+
1344
+ # Special case: if value is na, convert to na(Type) function call
1345
+ value = decl.value
1346
+ if isinstance(value, NaLiteral):
1347
+ value = self._na_initialiser(converted_type)
1348
+ else:
1349
+ value = self._transform_expression(value)
1350
+
1351
+ type_hint = self._series_annotation(
1352
+ decl.name, self._series_element_type(converted_type, decl.value), persistent=True)
1353
+ if isinstance(decl, VaripDecl) and type_hint:
1354
+ # varip persists across a bar's intrabar updates too: pynecore's IBPersistent (PyneComp)
1355
+ type_hint = 'IB' + type_hint
1356
+
1357
+ # a var local of a function types its method calls, as an assignment's local does
1358
+ if self.in_user_function and converted_type and converted_type != 'Any':
1359
+ self.current_function_params[decl.name] = converted_type
1360
+
1361
+ return Assignment(
1362
+ target=decl.name,
1363
+ value=value,
1364
+ type_hint=type_hint
1365
+ )
1366
+
1367
+ _NUMERIC_TYPES = frozenset({'int', 'float'})
1368
+
1369
+ def _condition(self, pine_expr: Expression, header: bool = False, negated: bool = False,
1370
+ ternary: Optional[TernaryOp] = None) -> Expression:
1371
+ """A Pine expression read as a condition: a number there is cast_bool(...) (PyneComp, see
1372
+ condition_typing.py). `header`: an if/while condition; `negated`: the operand of `not`."""
1373
+ transformed = self._transform_expression(pine_expr)
1374
+ typing = getattr(self, '_condition_typing', None)
1375
+ if typing is None:
1376
+ numeric = self.type_inference.infer_type_hint(pine_expr) in self._NUMERIC_TYPES
1377
+ else:
1378
+ colours = ternary is not None and (self.type_inference.is_colour_value(ternary.true_expr)
1379
+ or self.type_inference.is_colour_value(ternary.false_expr))
1380
+ numeric = (typing.is_number(pine_expr, header, colour_ternary=colours, ternary=ternary is not None)
1381
+ and (not negated or typing.is_pivot(pine_expr)))
1382
+ if numeric:
1383
+ self.import_resolver.uses_cast_bool = True
1384
+ return FunctionCall(func='cast_bool', args=[transformed], kwargs={})
1385
+ return transformed
1386
+
1387
+ def _transform_if_statement(self, stmt: IfStatement) -> IfStatement:
1388
+ """Transform if statement (Rule 15)."""
1389
+ condition = self._condition(stmt.condition, header=True)
1390
+ body = [self._transform_statement(s) for s in stmt.body]
1391
+
1392
+ elseifs = []
1393
+ for elif_cond, elif_body in stmt.elseifs:
1394
+ transformed_cond = self._condition(elif_cond, header=True)
1395
+ transformed_body = [self._transform_statement(s) for s in elif_body]
1396
+ elseifs.append((transformed_cond, transformed_body))
1397
+
1398
+ else_body = None
1399
+ if stmt.else_body:
1400
+ else_body = [self._transform_statement(s) for s in stmt.else_body]
1401
+
1402
+ return IfStatement(
1403
+ condition=condition,
1404
+ body=body,
1405
+ elseifs=elseifs,
1406
+ else_body=else_body
1407
+ )
1408
+
1409
+ def _transform_for_loop(self, stmt: ForLoop) -> ForLoop:
1410
+ """Transform for loop to range() (Rules 12-13)."""
1411
+ from_val = self._transform_expression(stmt.from_val)
1412
+ to_val = self._transform_expression(stmt.to_val)
1413
+ step = self._transform_expression(stmt.step) if stmt.step else None
1414
+ if step is None and self.pine_version < 6 and not stmt.is_assigned_value:
1415
+ # PyneComp spells out a Pine v4/v5 loop's default step (635 loops of the reference), not
1416
+ # for a loop given as the value of an assignment (probe tmp/probes/lv5.pine)
1417
+ step = Literal(value=1, literal_type='int')
1418
+ body = [self._transform_statement(s) for s in stmt.body]
1419
+ # a loop whose end may change in its body reads it before every pass: pine_loop (for_loop_end_bound.py)
1420
+ return ForLoop(
1421
+ var=stmt.var,
1422
+ from_val=from_val,
1423
+ to_val=to_val,
1424
+ step=step,
1425
+ body=body,
1426
+ counter_name=stmt.counter_name,
1427
+ counter_helper=stmt.counter_helper,
1428
+ range_helper=self._range_helper_name()
1429
+ )
1430
+
1431
+ def _range_helper_name(self) -> str:
1432
+ """The name `pine_range` is imported under: its own spelling unless the script uses that name
1433
+ (`pine_range = 2`), then a fresh one, as for `pine_loop` (card #94)."""
1434
+ if self._range_helper is None:
1435
+ if self._used_names is None:
1436
+ self._used_names = collect_names(self._names_source)
1437
+ self._range_helper = (fresh_name(RANGE_HELPER, self._used_names)
1438
+ if RANGE_HELPER in self._used_names else RANGE_HELPER)
1439
+ self.import_resolver.pine_range_name = self._range_helper
1440
+ return self._range_helper
1441
+
1442
+ def _transform_for_in_loop(self, stmt: ForInLoop) -> Union[ForInLoop, List[Statement]]:
1443
+ """Transform for-in loop (Rule 14)."""
1444
+ iterable = self._transform_expression(stmt.iterable)
1445
+ body = [self._transform_statement(s) for s in stmt.body]
1446
+ if len(stmt.vars) > 1:
1447
+ return self._key_value_loop(stmt, iterable, body)
1448
+ return ForInLoop(
1449
+ vars=stmt.vars,
1450
+ iterable=iterable,
1451
+ body=body
1452
+ )
1453
+
1454
+ def _key_value_loop(self, stmt: ForInLoop, iterable: Expression,
1455
+ body: List[Statement]) -> Union[ForInLoop, List[Statement]]:
1456
+ """`for [k, v] in x` (card #17): an array or a matrix gives (index, element)
1457
+ through enumerate(); a map gives (key, value) through `.items()`, as PyneComp
1458
+ writes it; a value of unknown type is read as an array."""
1459
+ iterable_type = self._iterable_type(stmt.iterable)
1460
+ if iterable_type and not iterable_type.startswith(MAP_TYPE_PREFIXES):
1461
+ return ForInLoop(vars=stmt.vars, iterable=FunctionCall(func='enumerate', args=[iterable], kwargs={}),
1462
+ body=body)
1463
+ if iterable_type: # a map: `for k, v in m.items():` (PyneComp)
1464
+ return ForInLoop(vars=stmt.vars, iterable=MethodCall(object=iterable, method='items', args=[], kwargs={}),
1465
+ body=body)
1466
+ # a type PyneComp does not know: an array's (index, element) (Trendoscope/Zigzag `_indicatorNames`)
1467
+ return ForInLoop(vars=stmt.vars, iterable=FunctionCall(func='enumerate', args=[iterable], kwargs={}), body=body)
1468
+
1469
+ def _iterable_type(self, pine_iterable: Expression) -> Optional[str]:
1470
+ """The static type of a for-in iterable, or None. A name or field path has
1471
+ its declared type; `m.keys()`/`m.values()` of a typed map is an array; any
1472
+ other expression counts only when the inference gives a collection
1473
+ (`array.from(...)`, `map.new<K, V>()`)."""
1474
+ if receiver_path(pine_iterable):
1475
+ return self._receiver_type(pine_iterable)
1476
+ if isinstance(pine_iterable, FunctionCall) and isinstance(pine_iterable.func, str):
1477
+ receiver, _, member = pine_iterable.func.rpartition('.')
1478
+ if member in ('keys', 'values') and receiver \
1479
+ and (self._receiver_type(receiver) or '').startswith(MAP_TYPE_PREFIXES):
1480
+ return 'list[Any]'
1481
+ inferred = self.type_inference.declared_type_name(self.type_inference.infer_type_hint(pine_iterable))
1482
+ return inferred if inferred and inferred.startswith(COLLECTION_TYPE_PREFIXES) else None
1483
+
1484
+ def _transform_while_loop(self, stmt: WhileLoop) -> WhileLoop:
1485
+ """Transform while loop."""
1486
+ condition = self._condition(stmt.condition, header=True)
1487
+ body = [self._transform_statement(s) for s in stmt.body]
1488
+ return WhileLoop(condition=condition, body=body)
1489
+
1490
+ def _transform_switch_statement(self, stmt: SwitchStatement) -> Union[IfStatement, List[Statement], None]:
1491
+ """Transform switch to if/elif/else chain (Rules 41-42)."""
1492
+ if not stmt.cases:
1493
+ # Default-only switch: the default always runs (card #11); a
1494
+ # subject is still evaluated, as Pine evaluates it
1495
+ subject = [ExpressionStatement(expr=self._transform_expression(stmt.expr))] if stmt.expr else []
1496
+ default_body = [self._transform_statement(s) for s in (stmt.default or [])]
1497
+ return self._flatten_statements(subject + default_body) or None
1498
+
1499
+ # Every case condition first, the first one binding the subject (card #43)
1500
+ first_condition, *elif_conditions = self._switch_case_conditions(stmt.expr, stmt.cases)
1501
+
1502
+ transformed_body = [self._transform_statement(s) for s in stmt.cases[0][1]]
1503
+ elseifs = [(condition, [self._transform_statement(s) for s in case_body])
1504
+ for condition, (_, case_body) in zip(elif_conditions, stmt.cases[1:])]
1505
+
1506
+ # Default case becomes else
1507
+ else_body = None
1508
+ if stmt.default:
1509
+ else_body = [self._transform_statement(s) for s in stmt.default]
1510
+
1511
+ return IfStatement(
1512
+ condition=first_condition,
1513
+ body=transformed_body,
1514
+ elseifs=elseifs,
1515
+ else_body=else_body
1516
+ )
1517
+
1518
+ def _switch_case_conditions(self, subject: Optional[Expression], cases) -> List[Expression]:
1519
+ """The condition of each case, in case order.
1520
+
1521
+ With a subject, the FIRST condition binds it, `(s := subject) == c1`, and
1522
+ the others read it, `s == c2`: the subject runs once, inside the first case
1523
+ test, which runs exactly when the switch does (card #43). A plain name or a
1524
+ literal is read again as is. Without a subject a case is its own condition.
1525
+ Every read is its own node.
1526
+
1527
+ Statement-position twin: `block_value_lowering._BlockValueLowering.switch_into`
1528
+ binds the subject of a switch lowered to statements with an assignment
1529
+ instead; a change to how one binds the subject belongs in the other too.
1530
+ """
1531
+ if subject is None:
1532
+ return [self._transform_expression(case) for case, _ in cases]
1533
+ subject_value = self._transform_expression(subject)
1534
+ read_again_as_is = isinstance(subject_value, Literal) or (
1535
+ isinstance(subject_value, Identifier) and '.' not in subject_value.name)
1536
+ if read_again_as_is:
1537
+ reads = [subject_value] + [dataclasses.replace(subject_value) for _ in cases[1:]]
1538
+ else:
1539
+ name = self._fresh_temporary_name('switch_subject')
1540
+ reads = [NamedExpression(name=name, value=subject_value)] + [Identifier(name=name) for _ in cases[1:]]
1541
+ return [BinaryOp(left=read, op='==', right=self._transform_expression(case))
1542
+ for read, (case, _) in zip(reads, cases)]
1543
+
1544
+ def _legacy_bool_cast(self, operand: Expression) -> TernaryOp:
1545
+ """Pine v5 `bool(x)`: `na(bool) if na(x) else bool(x)`. MEASURED on TradingView
1546
+ (2026-10-05, v5): `bool(1e-10)`, `bool(-1e-10)` and `bool(1.000001e-10)` are
1547
+ true, `bool(0.0)` is false, and `f(bool(2))` selects a bool overload; v5 has no
1548
+ 1e-10 tolerance, unlike v6's `cast_bool`. MEASURED again (2026-10-06, probe6):
1549
+ `bool()` of an na float or an na bool is na, typed or untyped, so the na branch is
1550
+ `na(bool)`, not False (it reads as False wherever a condition reads it); that is
1551
+ Pine v5's three-state bool, which the decorator's `na_bool=True` switches on. The
1552
+ card #79 reading `na=false` for `b_na = bool(naF)` is superseded (its script shape
1553
+ differed; the cause is UNPROVEN). Python's `bool` is true for nan, hence the `na`
1554
+ test. An operand that is not a name or a literal is evaluated once, into a temporary."""
1555
+ value = self._transform_expression(operand)
1556
+ if isinstance(value, Literal) or (isinstance(value, Identifier) and '.' not in value.name):
1557
+ tested, read = value, dataclasses.replace(value)
1558
+ else:
1559
+ temporary = self._fresh_temporary_name('bool_operand')
1560
+ tested, read = NamedExpression(name=temporary, value=value), Identifier(name=temporary)
1561
+ self.import_resolver.add_module('na')
1562
+ return TernaryOp(condition=FunctionCall(func='na', args=[tested], kwargs={}),
1563
+ true_expr=self._na_initialiser('bool'),
1564
+ false_expr=FunctionCall(func='bool', args=[read], kwargs={}))
1565
+
1566
+ def _free_spelling(self, preferred: str) -> str:
1567
+ """`preferred` when no name of the script or the module's own imports holds it, else the
1568
+ lowest free suffix of it; every spelling this returns is reserved for the rest of the run."""
1569
+ if self._identifier_names is None:
1570
+ self._identifier_names = (identifier_names(self._names_source) | EMITTED_HELPER_NAMES
1571
+ | set(self.import_resolver.aliased_modules.values()))
1572
+ return free_spelling(preferred, self._identifier_names)
1573
+
1574
+ def _fresh_temporary_name(self, stem: str) -> str:
1575
+ """A temporary (a switch subject, a method receiver) that no Pine name of the
1576
+ script uses. The names are collected on the first call, so a script without
1577
+ such a temporary pays nothing for them (card #43)."""
1578
+ if self._used_names is None:
1579
+ self._used_names = collect_names(self._names_source)
1580
+ return fresh_name(stem, self._used_names)
1581
+
1582
+ # ========================================================================
1583
+ # Expression transformations
1584
+ # ========================================================================
1585
+
1586
+ def _transform_expression(self, expr: Expression) -> Expression:
1587
+ """Transform expression."""
1588
+ if isinstance(expr, BinaryOp):
1589
+ if expr.op in ('and', 'or'):
1590
+ # an operand is cast only beside one PyneComp has a type for: `s and x` with `x` an untyped
1591
+ # parameter casts neither (probes cb16, cb17)
1592
+ typing = getattr(self, '_condition_typing', None)
1593
+ left = (self._condition(expr.left) if typing is None or typing.has_a_type(expr.right)
1594
+ else self._transform_expression(expr.left))
1595
+ right = (self._condition(expr.right) if typing is None or typing.has_a_type(expr.left)
1596
+ else self._transform_expression(expr.right))
1597
+ elif expr.op in ('==', '!=') and self._compares_a_bool(expr):
1598
+ # `flag == 1`: the number compared with a bool is cast (PyneComp)
1599
+ left, right = self._condition(expr.left), self._condition(expr.right)
1600
+ else:
1601
+ left, right = self._transform_expression(expr.left), self._transform_expression(expr.right)
1602
+ return BinaryOp(
1603
+ left=left,
1604
+ op=expr.op,
1605
+ right=right,
1606
+ # Pine v4/v5 `x += y` is printed `x = x + y` (PyneComp: 344 of 346 sites)
1607
+ compound=expr.compound and self.pine_version >= 6,
1608
+ )
1609
+ elif isinstance(expr, UnaryOp):
1610
+ return UnaryOp(
1611
+ op=expr.op,
1612
+ operand=(self._condition(expr.operand, negated=True) if expr.op == 'not'
1613
+ else self._transform_expression(expr.operand))
1614
+ )
1615
+ elif isinstance(expr, TernaryOp):
1616
+ return self._transform_ternary(expr)
1617
+ elif isinstance(expr, FunctionCall):
1618
+ return self._transform_function_call(expr)
1619
+ elif isinstance(expr, MethodCall):
1620
+ return self._transform_method_call(expr)
1621
+ elif isinstance(expr, MemberAccess):
1622
+ direct = self._directly_called_method(expr.object)
1623
+ if direct is not None:
1624
+ return MemberAccess(object=direct, member=expr.member)
1625
+ return self._transform_member_access(expr)
1626
+ elif isinstance(expr, IndexAccess):
1627
+ return self._transform_index_access(expr)
1628
+ elif isinstance(expr, Identifier):
1629
+ return self._transform_identifier(expr)
1630
+ elif isinstance(expr, Literal):
1631
+ return self._transform_literal(expr)
1632
+ elif isinstance(expr, NaLiteral):
1633
+ return self._transform_na_literal(expr)
1634
+ elif isinstance(expr, ArrayLiteral):
1635
+ return ArrayLiteral(elements=[self._transform_expression(e) for e in expr.elements])
1636
+ elif isinstance(expr, IfExpression):
1637
+ return self._transform_if_expression(expr)
1638
+ elif isinstance(expr, SwitchExpression):
1639
+ return self._transform_switch_expression(expr)
1640
+ else:
1641
+ return expr
1642
+
1643
+ def _compares_a_bool(self, expr: BinaryOp) -> bool:
1644
+ """`flag == 1`: one side of an equality is a bool."""
1645
+ typing = getattr(self, '_condition_typing', None)
1646
+ if typing is not None and (typing.is_bool(expr.left) or typing.is_bool(expr.right)):
1647
+ return True
1648
+ return 'bool' in (self.type_inference.infer_type_hint(expr.left), self.type_inference.infer_type_hint(expr.right))
1649
+
1650
+ def _transform_ternary(self, expr: TernaryOp) -> TernaryOp:
1651
+ """Transform ternary operator (Rule 10): cond ? a : b => a if cond else b"""
1652
+ # Note: We keep it as TernaryOp, codegen will convert to Python syntax
1653
+ return TernaryOp(
1654
+ condition=self._condition(expr.condition, ternary=expr),
1655
+ true_expr=self._transform_expression(expr.true_expr),
1656
+ false_expr=self._transform_expression(expr.false_expr)
1657
+ )
1658
+
1659
+ def _is_generic_array_of_objects(self, func_name: str, call: FunctionCall) -> bool:
1660
+ """`array.new<T>(...)` with arguments, for an element type that has no
1661
+ `array.new_*` factory (a UDT, an enum, `chart.point`, ...). A type the script
1662
+ neither declares nor imports is refused at its line (card #81). Without
1663
+ arguments it is `array.new(0, NA(T))`, as PyneComp writes it (probe gen1)."""
1664
+ if not func_name.startswith('array.new<'):
1665
+ return False
1666
+ element_type = func_name[func_name.index('<') + 1:func_name.rindex('>')].strip()
1667
+ if element_type in ARRAY_FACTORY_ELEMENT_TYPES:
1668
+ return False
1669
+ known = self._array_object_element_types()
1670
+ library_type = '.' in element_type and element_type[:element_type.index('.') + 1] in known
1671
+ if element_type not in known and not library_type:
1672
+ raise TransformerError(f"array.new<{element_type}>: '{element_type}' is not a type the script "
1673
+ 'declares or imports, nor a built-in type', call.line, call.column)
1674
+ return True
1675
+
1676
+ def _array_object_element_types(self) -> set:
1677
+ """The script's UDTs and enums, the built-in object types without an
1678
+ `array.new_*` factory, and each imported library alias (its `alias.Type`)."""
1679
+ if self._object_element_types is None:
1680
+ self._object_element_types = (
1681
+ {declaration.name for declaration in self._names_source.declarations
1682
+ if isinstance(declaration, (TypeDecl, EnumDecl))}
1683
+ | BUILTIN_OBJECT_ELEMENT_TYPES
1684
+ | {f'{library.alias or library.library}.' for library in self._names_source.imports})
1685
+ return self._object_element_types
1686
+
1687
+ def _generic_array_new(self, call: FunctionCall, func_name: str = '') -> FunctionCall:
1688
+ """`array.new(size, value)`: the runtime repeats the one value it is given, so
1689
+ every element is the same object (MEASURED on TradingView, 2026-10-05: after
1690
+ `arr.get(0).v := 99` every element reads 99). The value is evaluated once,
1691
+ at any size; an omitted value is `na` (card #81)."""
1692
+ args = [self._transform_expression(argument) for argument in call.args]
1693
+ kwargs = {name: self._transform_expression(value) for name, value in call.kwargs.items()}
1694
+ if not args and not kwargs:
1695
+ args = [Literal(value=0, literal_type='int')] # `array.new<T>()`: `array.new(0, NA(T))` (PyneComp)
1696
+ if len(args) + len(kwargs) == 1 and 'initial_value' not in kwargs: # a lone size gets T's na
1697
+ value = self._typed_na(func_name)
1698
+ if args:
1699
+ args.append(value)
1700
+ else:
1701
+ kwargs['initial_value'] = value
1702
+ self.import_resolver.add_module('array')
1703
+ return FunctionCall(func='array.new', args=args, kwargs=kwargs)
1704
+
1705
+ def _typed_na(self, func_name: str) -> Expression:
1706
+ """`NA(T)` for the element type of `array.new<T>`/`matrix.new<T>`; `na` when it has none."""
1707
+ if '<' not in func_name:
1708
+ self.import_resolver.add_module('na')
1709
+ return Identifier(name='na')
1710
+ element = func_name[func_name.index('<') + 1:func_name.rindex('>')].strip()
1711
+ python_type = convert_pine_generic_to_python(element)
1712
+ self.import_resolver.uses_na = True
1713
+ if python_type == 'ChartPoint':
1714
+ self.import_resolver.chart_types.add('ChartPoint')
1715
+ return FunctionCall(func='NA', args=[Identifier(name=python_type)], kwargs={})
1716
+
1717
+ def _generic_matrix_new(self, call: FunctionCall, func_name: str) -> FunctionCall:
1718
+ """`matrix.new<T>(rows, columns[, value])`: without a value PyneComp passes
1719
+ `initial_value=NA(T)`; an integer value of a float matrix is written as a float (probe gen1)."""
1720
+ element = func_name[func_name.index('<') + 1:func_name.rindex('>')].strip()
1721
+ args = [self._transform_expression(argument) for argument in call.args]
1722
+ kwargs = {name: self._transform_expression(value) for name, value in call.kwargs.items()}
1723
+ if len(args) < 3 and 'initial_value' not in kwargs:
1724
+ kwargs['initial_value'] = self._typed_na(func_name)
1725
+ elif len(args) >= 3 and isinstance(call.args[2], NaLiteral):
1726
+ args[2] = self._typed_na(func_name) # `matrix.new<string>(r, c, na)`: NA(str) (probe loop10)
1727
+ elif element == 'float' and len(args) >= 3 and isinstance(args[2], Literal) \
1728
+ and args[2].literal_type == 'int':
1729
+ args[2] = Literal(value=float(args[2].value), literal_type='float')
1730
+ self.import_resolver.add_module('matrix')
1731
+ return FunctionCall(func='matrix.new', args=args, kwargs=kwargs)
1732
+
1733
+ def _transform_generic_function_name(self, func_name: str) -> str:
1734
+ """Transform function name with generics."""
1735
+ if '<' not in func_name:
1736
+ return func_name
1737
+
1738
+ # For map.new<K,V>() -> map.new() (just strip generics)
1739
+ if 'map.new<' in func_name or func_name.startswith('map.new<'):
1740
+ return 'map.new'
1741
+
1742
+ # For array.new<T>() -> array.new_T()
1743
+ if 'array.new<' in func_name or func_name.startswith('array.new<'):
1744
+ # Extract the type from array.new<type>
1745
+ start = func_name.index('<') + 1
1746
+ end = func_name.index('>')
1747
+ type_param = func_name[start:end].strip()
1748
+
1749
+ # Map Pine Script types to PyneCore array.new_* functions; any other
1750
+ # declared type with arguments is routed to array.new() before this (card #81)
1751
+ return ARRAY_FACTORIES.get(type_param, 'list')
1752
+
1753
+ # For matrix.new<T>() -> matrix.new() (just strip generics)
1754
+ if 'matrix.new<' in func_name or func_name.startswith('matrix.new<'):
1755
+ return 'matrix.new'
1756
+
1757
+ # For other generic functions, strip generics for now
1758
+ base_name = func_name[:func_name.index('<')]
1759
+ return base_name
1760
+
1761
+ def _parse_dotted_path(self, path: str) -> Expression:
1762
+ """Parse a dotted path string like 'obj.lbl' into nested MemberAccess expressions."""
1763
+ parts = path.split('.')
1764
+ if len(parts) == 1:
1765
+ return Identifier(name=parts[0])
1766
+
1767
+ # Build nested MemberAccess from left to right
1768
+ expr = Identifier(name=parts[0])
1769
+ for part in parts[1:]:
1770
+ expr = MemberAccess(object=expr, member=part)
1771
+ return expr
1772
+
1773
+ @staticmethod
1774
+ def _module_of_type(type_str: Optional[str]) -> Optional[str]:
1775
+ """The builtin namespace of a declared receiver type (`list[float]` -> 'array'),
1776
+ or None for an unknown, UDT or scalar type. Callers pass the type already
1777
+ unwrapped (TypeInference.declared_type_name)."""
1778
+ if not type_str:
1779
+ return None
1780
+ if type_str.startswith('list'):
1781
+ return 'array'
1782
+ if type_str.startswith('dict'):
1783
+ return 'map'
1784
+ if type_str in ('line', 'label', 'box', 'table', 'linefill', 'polyline',
1785
+ 'Line', 'Label', 'Box', 'Table', 'LineFill', 'Polyline'):
1786
+ return type_str.lower()
1787
+ if type_str.startswith('matrix') or type_str.startswith('Matrix'):
1788
+ return 'matrix'
1789
+ if '<' in type_str:
1790
+ return type_str[:type_str.index('<')]
1791
+ return None
1792
+
1793
+ def _resolve_module_for_method(self, method_name: str, receiver_type: Optional[str]) -> Optional[str]:
1794
+ """The one routing rule for a builtin method, whatever the receiver's shape (card #8):
1795
+ the namespace of the receiver's declared type when it defines the method; else the
1796
+ one method namespace that defines it, statically; else RUNTIME_METHOD_DISPATCH when
1797
+ several do (pynecore picks by the receiver's class); None when none does."""
1798
+ namespaces = method_namespaces(method_name)
1799
+ typed_module = self._module_of_type(receiver_type)
1800
+ if typed_module in namespaces:
1801
+ return typed_module
1802
+ if len(namespaces) == 1:
1803
+ return namespaces[0]
1804
+ return RUNTIME_METHOD_DISPATCH if namespaces else None
1805
+
1806
+ def _receiver_type(self, receiver) -> Optional[str]:
1807
+ """Declared type of a method-call receiver in the current scope, or None."""
1808
+ return self.type_inference.infer_receiver_type(
1809
+ receiver, self.current_function_params, self.current_function_shadowed_names)
1810
+
1811
+ def _resolve_module_for_receiver(self, method_name: str, receiver) -> Optional[str]:
1812
+ """Module for `receiver.method_name()` by the routing rule, from the receiver's type. A receiver
1813
+ PyneComp has no declared type for goes through `method_call`, even where one namespace
1814
+ defines the method (probe mc1: `l = line.new(...)`, `l.set_x2(x)`)."""
1815
+ receiver_type = self._routing_receiver_type(receiver)
1816
+ namespaces = method_namespaces(method_name)
1817
+ if receiver_type is None and namespaces:
1818
+ return RUNTIME_METHOD_DISPATCH
1819
+ return self._resolve_module_for_method(method_name, receiver_type)
1820
+
1821
+ _COLLECTION_CONSTRUCTORS = ('array.new', 'array.from', 'map.new', 'matrix.new')
1822
+
1823
+ def _routing_receiver_type(self, receiver) -> Optional[str]:
1824
+ """The receiver type PyneComp routes `receiver.method()` by: only a type it reads off a
1825
+ declaration (probe scripts mc1/mc2, PyneComp v6.0.80). A name declared with a type, a typed
1826
+ parameter, a name whose value is a collection constructor (`array.new<line>()`,
1827
+ `array.from(...)`, `map.new<K, V>()`) or a UDT's `T.new()`, a field of such a value and a
1828
+ for-in variable over a typed collection have it; a drawing (`l = line.new(...)`), a copy
1829
+ (`l6 = l1`) and a function's result do not: those go through `method_call`."""
1830
+ if isinstance(receiver, IndexAccess):
1831
+ return None # a history read (`a[1].get(i)`) has no declared type (chrono_utils)
1832
+ path = receiver_path(receiver)
1833
+ if not path or path[0] not in self._declared_receiver_names() \
1834
+ or path[0] in getattr(self, '_untyped_parameters', ()):
1835
+ return None
1836
+ receiver_type = self._receiver_type(receiver)
1837
+ if receiver_type is None:
1838
+ # `var w = T.new().init()` (chrono_utils, biased_price_target), or a for-in variable
1839
+ scope = getattr(self, '_typing_scope', None)
1840
+ owner = (scope.name, scope.line) if scope is not None else None
1841
+ constructed = getattr(self, '_constructed_types', {})
1842
+ local = path[0] in self.current_function_shadowed_names or path[0] in (self.current_function_params or {})
1843
+ element = constructed.get((owner, path[0])) or (None if local else constructed.get((None, path[0])))
1844
+ element = element or self._loop_element_type(path[0])
1845
+ if element is not None and len(path) == 1:
1846
+ return element
1847
+ for field in path[1:] if element is not None else ():
1848
+ # a field of a for-in variable (`zigzagObject.zigzagLine`, Trendoscope/Zigzag)
1849
+ element = self.symbol_table.udt_field_types.get(
1850
+ self.type_inference.declared_type_name(element) or element, {}).get(field)
1851
+ if element is None:
1852
+ break
1853
+ else:
1854
+ return element
1855
+ return receiver_type
1856
+
1857
+ def _loop_element_type(self, name: str) -> Optional[str]:
1858
+ """The element type of the collection a for-in variable named `name` walks, where it types."""
1859
+ for iterable in self._loop_iterables.get(name, ()):
1860
+ container = self._receiver_type(iterable) or ''
1861
+ if container.startswith('list[') and container.endswith(']'):
1862
+ return container[len('list['):-1] # a for-in variable: its collection's element type
1863
+ if container.startswith('dict[') and container.endswith(']') and ',' in container:
1864
+ return container[container.index(',') + 1:-1].strip() # `for [k, v] in m`: the map's values
1865
+ return None
1866
+
1867
+ def _declared_receiver_names(self) -> frozenset:
1868
+ names = getattr(self, '_declared_receivers', None)
1869
+ if names is not None:
1870
+ return names
1871
+ type_names = {node.name for node in self._names_source.declarations if isinstance(node, (TypeDecl, EnumDecl))}
1872
+
1873
+ aliases = set(self._library_aliases())
1874
+
1875
+ def constructed_type(value) -> Optional[str]:
1876
+ """`T.new()`, `lib.T.new()` or a method chain on one (`T.new().init()`, probe mc5): T."""
1877
+ while isinstance(value, MethodCall):
1878
+ value = value.object
1879
+ if isinstance(value, FunctionCall) and isinstance(value.func, str):
1880
+ head, _, member = value.func.partition('<')[0].rpartition('.')
1881
+ if member == 'new' and (head in type_names or (head.partition('.')[0] in aliases
1882
+ and head.partition('.')[2][:1].isupper())):
1883
+ return head
1884
+ return None
1885
+
1886
+ drawings = {'line': 'Line', 'label': 'Label', 'box': 'Box', 'table': 'Table', 'linefill': 'LineFill',
1887
+ 'polyline': 'Polyline'}
1888
+
1889
+ def all_drawings(value) -> Optional[str]:
1890
+ """`line.all`: list[Line] (toodegrees/forex_factory_utility)."""
1891
+ dotted = value.name if isinstance(value, Identifier) else (
1892
+ f'{value.object}.{value.member}' if isinstance(value, MemberAccess) and isinstance(value.object, str)
1893
+ else None)
1894
+ if dotted and dotted.endswith('.all') and dotted[:-len('.all')] in drawings:
1895
+ return f'list[{drawings[dotted[:-len(".all")]]}]'
1896
+ return None
1897
+
1898
+ def typed_value(value) -> bool:
1899
+ if isinstance(value, FunctionCall) and isinstance(value.func, str) \
1900
+ and value.func.startswith(self._COLLECTION_CONSTRUCTORS):
1901
+ return True
1902
+ return constructed_type(value) is not None or all_drawings(value) is not None
1903
+
1904
+ declared = set()
1905
+ loops = []
1906
+ # the type a name is constructed with, per function (`var a = array.new<float>()`, `var w = T.new()`)
1907
+ self._constructed_types = {}
1908
+ owners = {}
1909
+ for function in walk(self._names_source):
1910
+ if isinstance(function, FuncDecl):
1911
+ for inner in walk(function.body):
1912
+ owners.setdefault(id(inner), (function.name, function.line))
1913
+ for node in walk(self._names_source):
1914
+ if isinstance(node, (Assignment, VarDecl, VaripDecl)):
1915
+ name = node.target if isinstance(node, Assignment) else node.name
1916
+ if isinstance(name, str) and (getattr(node, 'type_hint', None) or typed_value(node.value)):
1917
+ declared.add(name)
1918
+ key = (owners.get(id(node)), name)
1919
+ if constructed_type(node.value):
1920
+ self._constructed_types.setdefault(key, constructed_type(node.value))
1921
+ elif all_drawings(node.value):
1922
+ self._constructed_types.setdefault(key, all_drawings(node.value))
1923
+ elif isinstance(node.value, FunctionCall) and isinstance(node.value.func, str) \
1924
+ and re.fullmatch(r'(array|map|matrix)\.new<.+>', node.value.func):
1925
+ # `var a = array.new<float>()` in a block (series_collection)
1926
+ generic = node.value.func.replace('.new', '', 1)
1927
+ self._constructed_types.setdefault(key, convert_pine_generic_to_python(generic))
1928
+ elif isinstance(node.value, FunctionCall) and node.value.func == 'array.from' and node.value.args:
1929
+ element = self.type_inference.infer_type_hint(node.value.args[0]) # `array.from(0., 1.)`
1930
+ if element in ('int', 'float', 'bool', 'string', 'color'):
1931
+ self._constructed_types.setdefault(key, convert_pine_generic_to_python(f'array<{element}>'))
1932
+ elif isinstance(node, InputDecl):
1933
+ declared.add(node.name)
1934
+ elif isinstance(node, FuncDecl):
1935
+ declared.update(parameter.name for parameter in node.params if parameter.type_hint)
1936
+ elif isinstance(node, ForInLoop):
1937
+ loops.append(node)
1938
+ self._loop_iterables = {}
1939
+ for loop in loops: # a for-in variable over a typed collection
1940
+ path = receiver_path(loop.iterable)
1941
+ if path and path[0] in declared:
1942
+ declared.update(loop.vars)
1943
+ # every loop of the name, any function: the one whose iterable types where it is read wins
1944
+ if len(loop.vars) == 1:
1945
+ self._loop_iterables.setdefault(loop.vars[0], []).append(loop.iterable)
1946
+ elif len(loop.vars) == 2: # `for [k, v] in m`: the value is an element
1947
+ self._loop_iterables.setdefault(loop.vars[1], []).append(loop.iterable)
1948
+ self._declared_receivers = frozenset(declared)
1949
+ return self._declared_receivers
1950
+
1951
+ def _method_signatures(self, method_name: str) -> List[tuple]:
1952
+ """(`this` type, required, total arguments after `this`) of every user method named `method_name`."""
1953
+ return [(parameters[0].type_hint, sum(1 for parameter in parameters[1:] if parameter.default is None),
1954
+ len(parameters) - 1)
1955
+ for node in walk(self._names_source)
1956
+ if isinstance(node, FuncDecl) and node.is_method and node.name == method_name
1957
+ for parameters in [node.params] if parameters]
1958
+
1959
+ def _user_method_route(self, method_name: str, receiver, call=None) -> Optional[str]:
1960
+ """How `receiver.method_name()` reaches a user method of that name (card #8):
1961
+ USER_METHOD, RUNTIME_CHOICE, or None for the builtin route (also when no user
1962
+ method has the name). A typed receiver takes the user method unless its builtin
1963
+ namespace defines the name and no user method is declared on that namespace's
1964
+ type. An untyped receiver whose method name a UDT method shares with a builtin
1965
+ namespace is decided at run time by its class."""
1966
+ this_types = self.method_functions.get(method_name)
1967
+ if this_types is None:
1968
+ return None
1969
+ namespaces = method_namespaces(method_name)
1970
+ receiver_type = self._routing_receiver_type(receiver)
1971
+ receiver_module = self._module_of_type(receiver_type)
1972
+ if not namespaces or (receiver_type and receiver_module not in namespaces):
1973
+ return USER_METHOD
1974
+ if receiver_type:
1975
+ # a builtin namespace has the name too: the user method only for its own `this` type and a call it
1976
+ # takes (Trendoscope/Zigzag: `values.push(x)` on array<float> is array.push, `this.clear()` on
1977
+ # array<ZigzagDrawing> the user method)
1978
+ wanted = _spelled_type(receiver_type)
1979
+ arguments = None if call is None else len(call.args) + len(call.kwargs)
1980
+ return USER_METHOD if any(
1981
+ _spelled_type(this_type) == wanted and (arguments is None or required <= arguments <= total)
1982
+ for this_type, required, total in self._method_signatures(method_name)) else None
1983
+ udt_names = self.symbol_table.udt_field_types
1984
+ return RUNTIME_CHOICE if all(this_type in udt_names for this_type in this_types) else USER_METHOD
1985
+
1986
+ def _route_method_call(self, call, method_name: str, receiver, make_receiver) -> Optional[Expression]:
1987
+ """`receiver.method_name(...)` as a function call, or None when no route applies.
1988
+ `receiver` is the Pine receiver (a node or dotted path); `make_receiver()` builds
1989
+ a fresh transformed receiver expression each time it is called."""
1990
+ user_methods = self._user_methods()
1991
+ # on a value that is no name (`T.new().m()`, `matrix.row(m, r).nonZero()`, `(a * b).m()`) PyneComp
1992
+ # calls the method directly only when every declaration of its name is exported (probe mc5, the
1993
+ # reference's jason5480 and Trendoscope libraries)
1994
+ typed_arm = isinstance(receiver, TernaryOp) and any(
1995
+ receiver_path(arm) is not None and self._has_a_known_type(arm)
1996
+ for arm in (receiver.true_expr, receiver.false_expr)) # `(c ? T.new() : this.t).m()` (chrono_utils)
1997
+ if typed_arm and method_name in user_methods:
1998
+ return self._user_method_call(method_name, receiver, make_receiver(), call)
1999
+ # an inline input hoisted into a parameter (`input.enum(...).to_string()`) is still a call's result
2000
+ hoisted_input = isinstance(receiver, Identifier) and re.fullmatch(r'\w+__input|__input_\d+__', receiver.name)
2001
+ if (receiver_path(receiver) is None and not isinstance(receiver, Literal)) or hoisted_input:
2002
+ dispatched = method_name in user_methods and not user_methods[method_name]
2003
+ if method_name in user_methods and not dispatched: # `clear(delete(this))` (Trendoscope/Drawing)
2004
+ return self._user_method_call(method_name, receiver, make_receiver(), call)
2005
+ else:
2006
+ dispatched = method_name in user_methods and not self._has_a_known_type(receiver)
2007
+ if dispatched:
2008
+ # PyneComp: a user method on a receiver it has no declared type for is chosen at run time,
2009
+ # `method_call(m, x)`; an exported method by its name, `method_call('m', x)` (probes mc3, mc4)
2010
+ args = [make_receiver()] + [self._transform_expression(arg) for arg in call.args]
2011
+ kwargs = {k: self._transform_expression(v) for k, v in call.kwargs.items()}
2012
+ callee = (Literal(value=method_name, literal_type='string') if user_methods[method_name]
2013
+ else Identifier(name=method_name))
2014
+ self.import_resolver.uses_method_call = True
2015
+ return FunctionCall(func=RUNTIME_METHOD_DISPATCH, args=[callee] + args, kwargs=kwargs)
2016
+ user_route = self._user_method_route(method_name, receiver, call)
2017
+ if user_route == USER_METHOD:
2018
+ return self._user_method_call(method_name, receiver, make_receiver(), call)
2019
+ if user_route == RUNTIME_CHOICE:
2020
+ return self._runtime_chosen_method_call(method_name, receiver, make_receiver, call)
2021
+ module_name = self._resolve_module_for_receiver(method_name, receiver)
2022
+ if module_name is None and method_name not in user_methods and not method_namespaces(method_name) \
2023
+ and self._library_aliases():
2024
+ # a method an imported library exports (`labels.flush()`): chosen at run time (Trendoscope/Zigzag)
2025
+ module_name = RUNTIME_METHOD_DISPATCH
2026
+ if module_name is None:
2027
+ return None
2028
+ return self._builtin_method_call(module_name, method_name, make_receiver(), call)
2029
+
2030
+ def _has_a_known_type(self, receiver) -> bool:
2031
+ """A receiver whose type PyneComp knows for a user method: a declared one, an input, a
2032
+ built-in series (`close.f()`) or a literal (probes mc3, mc4 and the reference)."""
2033
+ if isinstance(receiver, (Literal,)) or self._routing_receiver_type(receiver) is not None:
2034
+ return True
2035
+ path = receiver_path(receiver)
2036
+ if not path or len(path) != 1:
2037
+ return False
2038
+ name = path[0]
2039
+ if name in SERIES_VARIABLES:
2040
+ return True
2041
+ return any(isinstance(node, InputDecl) and node.name == name for node in self._names_source.declarations)
2042
+
2043
+ def _user_methods(self) -> Dict[str, bool]:
2044
+ """Every user method's name -> whether all its declarations are exported (PyneComp then names
2045
+ it `method_call('m', x)`, else `method_call(m, x)`: probes mc4, mc5)."""
2046
+ methods = getattr(self, '_user_method_names', None)
2047
+ if methods is None:
2048
+ methods = {}
2049
+ for node in walk(self._names_source):
2050
+ if isinstance(node, FuncDecl) and node.is_method:
2051
+ methods[node.name] = methods.get(node.name, True) and node.is_export
2052
+ self._user_method_names = methods
2053
+ return methods
2054
+
2055
+ def _user_method_call(self, method_name: str, receiver, receiver_expr, call) -> FunctionCall:
2056
+ """`method_name(receiver, ...)`, a call of the user method."""
2057
+ pine_receiver = self._parse_dotted_path(receiver) if isinstance(receiver, str) else receiver
2058
+ args = [receiver_expr] + [self._transform_expression(arg) for arg in call.args]
2059
+ kwargs = {k: self._transform_expression(v) for k, v in call.kwargs.items()}
2060
+ args, kwargs = self._overload_arguments.coerce(
2061
+ call, method_name, [pine_receiver, *call.args], args, call.kwargs, kwargs)
2062
+ return FunctionCall(func=method_name, args=args, kwargs=kwargs)
2063
+
2064
+ def _builtin_method_call(self, module_name: str, method_name: str, receiver_expr, call) -> FunctionCall:
2065
+ """`module.method(receiver, ...)`, or `method_call('method', receiver, ...)` to let
2066
+ pynecore pick the namespace from the receiver's class. A keyword the scope
2067
+ renamer renamed for a user method of the same name keeps its Pine name."""
2068
+ args = [receiver_expr] + [self._transform_expression(arg) for arg in call.args]
2069
+ pine_keywords = self._pine_keywords.get(method_name, {})
2070
+ kwargs = {pine_keywords.get(k, k): self._transform_expression(v) for k, v in call.kwargs.items()}
2071
+ if module_name == RUNTIME_METHOD_DISPATCH:
2072
+ self.import_resolver.uses_method_call = True
2073
+ method_literal = Literal(value=method_name, literal_type='string')
2074
+ return FunctionCall(func=RUNTIME_METHOD_DISPATCH, args=[method_literal] + args, kwargs=kwargs)
2075
+ self.import_resolver.add_module(module_name)
2076
+ return FunctionCall(func=f'{module_name}.{method_name}', args=args, kwargs=kwargs)
2077
+
2078
+ def _runtime_chosen_method_call(self, method_name: str, receiver, make_receiver, call) -> TernaryOp:
2079
+ """`m(x) if isinstance(x, Z) else <builtin route>(x)`: an untyped receiver runs the
2080
+ user method when it is an instance of the method's UDT, else the builtin. A direct
2081
+ call keeps the user method's per-call-site state, which pynecore's callable
2082
+ `method_call(m, x)` would share across call sites. A receiver that is not a name
2083
+ or field path (a call result) is evaluated once, into a temporary."""
2084
+ classes = [Identifier(name=this_type) for this_type in sorted(self.method_functions[method_name])]
2085
+ udt_classes = classes[0] if len(classes) == 1 else ArrayLiteral(elements=classes)
2086
+ builtin_module = self._resolve_module_for_method(method_name, None)
2087
+ if receiver_path(receiver):
2088
+ tested_receiver, read_receiver = make_receiver(), make_receiver
2089
+ else:
2090
+ temporary = self._fresh_temporary_name('method_receiver')
2091
+ tested_receiver = NamedExpression(name=temporary, value=make_receiver())
2092
+ read_receiver = lambda: Identifier(name=temporary) # noqa: E731 - the arms read the temporary
2093
+ return TernaryOp(
2094
+ condition=FunctionCall(func='isinstance', args=[tested_receiver, udt_classes], kwargs={}),
2095
+ true_expr=self._user_method_call(method_name, receiver, read_receiver(), call),
2096
+ false_expr=self._builtin_method_call(builtin_module, method_name, read_receiver(), call))
2097
+
2098
+ def _transform_dotted_string_method_call(self, call: FunctionCall, func: str) -> Optional[Expression]:
2099
+ """Handle var.method() → module.method(var, ...) for dotted string function names.
2100
+
2101
+ Routes a user method, or a receiver typed as a builtin namespace; returns None
2102
+ for the rest, which the chained handler takes (it skips module names: `math.max`).
2103
+ """
2104
+ var_name, _, method_name = func.partition('.')
2105
+ if not method_name or '.' in method_name or self._names_a_type(var_name): # Not a variable method call
2106
+ return None
2107
+ if self._names_a_namespace(var_name): # `array.push(a, x)` is a namespace function
2108
+ return None
2109
+ if method_name == 'copy' and not call.args and not call.kwargs and 'copy' not in self._user_methods():
2110
+ self.import_resolver.uses_udt_copy = True # `d.copy()` is `udt_copy(d)`, an array too (reference)
2111
+ return FunctionCall(func='udt_copy', args=[self._transform_expression(Identifier(name=var_name))],
2112
+ kwargs={})
2113
+ if method_name not in self.method_functions and not self._module_of_type(self._receiver_type(var_name)):
2114
+ return None
2115
+ return self._route_method_call(call, method_name, Identifier(name=var_name), lambda: Identifier(name=var_name))
2116
+
2117
+ def _transform_chained_dotted_method_call(self, call: FunctionCall, func: str) -> Optional[Expression]:
2118
+ """Handle obj.field.method() → module.method(obj.field, ...) for chained access.
2119
+
2120
+ Returns transformed FunctionCall or None if not a chained method pattern.
2121
+ """
2122
+ all_variable, _, all_method_name = func.rpartition('.')
2123
+ if all_variable in DRAWING_ALL_VARIABLES: # box.all.size() -> array.size(box.all) (#37)
2124
+ return self._builtin_method_call('array', all_method_name, Identifier(name=all_variable), call)
2125
+
2126
+ first_component = func.split('.')[0]
2127
+ if (first_component in KNOWN_MODULES or self._names_a_type(first_component)
2128
+ or first_component in self.import_resolver.aliased_modules.values()
2129
+ or first_component in self._library_aliases()):
2130
+ return None
2131
+
2132
+ obj_path, _, method_name = func.rpartition('.') # "obj.lbl", "set_x"
2133
+
2134
+ # Special case: .copy() on object fields -> udt_copy()
2135
+ # Must be checked BEFORE set matching (Location 1 behavior)
2136
+ if method_name == 'copy' and self._user_method_route(method_name, obj_path) is None:
2137
+ obj_expr = self._parse_dotted_path(obj_path)
2138
+ transformed_args = [obj_expr] + [self._transform_expression(arg) for arg in call.args]
2139
+ transformed_kwargs = {k: self._transform_expression(v) for k, v in call.kwargs.items()}
2140
+ self.import_resolver.uses_udt_copy = True
2141
+ return FunctionCall(func='udt_copy', args=transformed_args, kwargs=transformed_kwargs)
2142
+
2143
+ return self._route_method_call(call, method_name, obj_path, lambda: self._parse_dotted_path(obj_path))
2144
+
2145
+ def _directly_called_method(self, value) -> Optional[FunctionCall]:
2146
+ """`x.m(...)` whose result is read for a field (`buf.rotate(n).data`): PyneComp calls the user method
2147
+ directly, whatever it knows of `x` (jason5480/series_collection)."""
2148
+ if not (isinstance(value, FunctionCall) and isinstance(value.func, str) and '.' in value.func):
2149
+ return None
2150
+ receiver, _, method_name = value.func.rpartition('.')
2151
+ if method_name not in self._user_methods() or self._names_a_namespace(receiver.split('.')[0]) \
2152
+ or self._names_a_type(receiver.split('.')[0]):
2153
+ return None
2154
+ return self._user_method_call(method_name, receiver, self._parse_dotted_path(receiver), value)
2155
+
2156
+ def _names_a_user_type(self, name: str) -> bool:
2157
+ return any(isinstance(node, TypeDecl) and node.name == name for node in self._names_source.declarations)
2158
+
2159
+ def _names_a_type(self, name: str) -> bool:
2160
+ """`T.new()`, `E.member`: a capitalised name that is no variable of the script (`EMSP.repeat()`
2161
+ on `const string EMSP` is a method call)."""
2162
+ if any(isinstance(node, (TypeDecl, EnumDecl)) and node.name == name for node in self._names_source.declarations):
2163
+ return True
2164
+ return name[:1].isupper() and name not in self._declared_receiver_names() \
2165
+ and self.symbol_table.lookup(name) is None
2166
+
2167
+ def _names_a_namespace(self, name: str) -> bool:
2168
+ """A pynecore namespace or an imported library alias, not a series variable:
2169
+ `close.m()` is a method call on the series, `math.abs(x)` is not."""
2170
+ return ((name in KNOWN_MODULES and not is_series_variable(name))
2171
+ or name in self.import_resolver.aliased_modules.values()
2172
+ or name in self._library_aliases())
2173
+
2174
+ def _library_aliases(self) -> set:
2175
+ """The names the script's `import user/lib/version as alias` lines bind (an unaliased import binds the library name):
2176
+ `lib.max(a, b)` calls the library's `max`, it is not a method call on `lib`."""
2177
+ return {imported.alias or imported.library for imported in self._names_source.imports}
2178
+
2179
+ def _transform_member_access_method_call(self, call: FunctionCall, func: MemberAccess) -> Optional[Expression]:
2180
+ """Handle MemberAccess-based method calls like obj.lbl.set_x(...).
2181
+
2182
+ Returns transformed FunctionCall or None if not a recognized method.
2183
+ """
2184
+ return self._route_method_call(call, func.member, func.object,
2185
+ lambda: self._transform_expression(func.object))
2186
+
2187
+ def _transform_function_call(self, call: FunctionCall) -> Expression:
2188
+ """Transform function call (Rules 23-25, 49-51)."""
2189
+ pine_call = call # the node as parsed; `call` may be rebuilt below (card #39)
2190
+ func = call.func
2191
+ pine_name = func if isinstance(func, str) else None
2192
+ if isinstance(func, str):
2193
+ # Apply renamed function mapping (e.g., pass -> pass_)
2194
+ if func in self._renamed_functions:
2195
+ func = self._renamed_functions[func]
2196
+ call = FunctionCall(func=func, args=call.args, kwargs=call.kwargs)
2197
+
2198
+ # Convert excess positional args to keyword args for input.* calls.
2199
+ # Pine Script allows input.int(defval, title, minval, maxval, ...)
2200
+ # but PyneCore requires minval=, maxval=, ... as keyword-only.
2201
+ if is_input_function(func) and len(call.args) > 2:
2202
+ new_args, positional_keywords = bind_input_positional_args(func, call.args)
2203
+ call = FunctionCall(func=func, args=new_args, kwargs={**call.kwargs, **positional_keywords})
2204
+
2205
+ # Handle method calls disguised as dotted function calls
2206
+ if '.' in func and '<' not in func and '.' not in func.split('.')[0]:
2207
+ # Try var.method() → module.method(var, ...)
2208
+ result = self._transform_dotted_string_method_call(call, func)
2209
+ if result:
2210
+ return result
2211
+
2212
+ # Try chained member access: obj.lbl.set_x → label.set_x(obj.lbl, ...)
2213
+ if '.' in func:
2214
+ result = self._transform_chained_dotted_method_call(call, func)
2215
+ if result:
2216
+ return result
2217
+
2218
+ # `array.max(a, nth)` / `array.min(a, nth)` stay as written (PyneComp, jordanfray library)
2219
+ # Handle generic syntax (map.new<K,V>, array.new<T>, etc.)
2220
+ if '<' in func and self._is_generic_array_of_objects(func, call):
2221
+ return self._generic_array_new(call, func)
2222
+ if func.startswith('matrix.new<'):
2223
+ return self._generic_matrix_new(call, func)
2224
+ if '<' in func:
2225
+ func = self._transform_generic_function_name(func)
2226
+ elif func.endswith('.copy') and self._names_a_user_type(func[:-len('.copy')]) and len(call.args) == 1:
2227
+ # `T.copy(x)` is `udt_copy(x)` (PyneComp, Trendoscope/Zigzag)
2228
+ self.import_resolver.uses_udt_copy = True
2229
+ return FunctionCall(func='udt_copy', args=[self._transform_expression(call.args[0])], kwargs={})
2230
+ elif '.new' in func:
2231
+ base_name = func.replace('.new', '')
2232
+ # a UDT constructor, `T.new(...)` -> `T(...)`; a library's type keeps `lib.T.new(...)` (PyneComp)
2233
+ if base_name and '.' not in base_name and (
2234
+ base_name[0].isupper() or base_name in self.symbol_table.udt_field_types):
2235
+ func = base_name
2236
+ elif func in _NA_CAST_TYPES and len(call.args) == 1 and not call.kwargs \
2237
+ and isinstance(call.args[0], NaLiteral):
2238
+ # `float(na)`, `color(na)`, `box(na)`: a typed na, NA(T) (PyneComp)
2239
+ return FunctionCall(func='NA', args=[Identifier(name=_NA_CAST_TYPES[func])], kwargs={})
2240
+ elif func == 'int':
2241
+ func = 'cast_int'
2242
+ self.import_resolver.uses_cast_int = True
2243
+ elif func == 'float':
2244
+ func = 'cast_float'
2245
+ self.import_resolver.uses_cast_float = True
2246
+ elif func == 'bool' and self.bool_is_never_na and len(call.args) + len(call.kwargs) == 1 \
2247
+ and not isinstance([*call.args, *call.kwargs.values()][0], NaLiteral):
2248
+ # Pine v6: na, nan and |x| <= 1e-10 are false; Python's bool(nan) is true (card #56)
2249
+ func = 'cast_bool'
2250
+ self.import_resolver.uses_cast_bool = True
2251
+ elif func == 'bool' and not self.bool_is_never_na and len(call.args) + len(call.kwargs) == 1 \
2252
+ and not isinstance([*call.args, *call.kwargs.values()][0], NaLiteral):
2253
+ # Pine v5 and earlier: any nonzero number is true, 0 and na false (card #79)
2254
+ return self._legacy_bool_cast([*call.args, *call.kwargs.values()][0])
2255
+ elif func in ('color', 'bool', 'string'):
2256
+ # Type cast: color(na) → na, color(x) → x
2257
+ # When arg is na, return plain na (Pine na is untyped at runtime)
2258
+ if len(call.args) == 1 and isinstance(call.args[0], NaLiteral):
2259
+ self.import_resolver.add_module('na')
2260
+ return NaLiteral()
2261
+ # For non-na args, pass through as identity (type casts are no-ops in Python)
2262
+ if len(call.args) == 1:
2263
+ return self._transform_expression(call.args[0])
2264
+ func = func # keep as-is for module usage
2265
+ elif func in DRAWING_CAST_FUNCTIONS and (len(call.args), set(call.kwargs)) in ((1, set()), (0, {'x'})):
2266
+ return self._drawing_type_cast(func, call.args[0] if call.args else call.kwargs['x'])
2267
+ else:
2268
+ func = get_function_name(func)
2269
+ if '.' in func:
2270
+ parts = func.split('.')
2271
+ parts[0] = get_module_name(parts[0])
2272
+ func = '.'.join(parts)
2273
+
2274
+ elif isinstance(func, MemberAccess):
2275
+ result = self._transform_member_access_method_call(call, func)
2276
+ if result:
2277
+ return result
2278
+
2279
+ # Handle generics in member access
2280
+ if '<' in func.member:
2281
+ func.member = self._transform_generic_function_name(f"{func.object if isinstance(func.object, str) else 'obj'}.{func.member}")
2282
+ if func.member in ('dict', 'list'):
2283
+ func = func.member
2284
+ else:
2285
+ func = self._transform_member_access(func)
2286
+
2287
+ # Transform arguments; a condition argument holding a number is cast_bool(...) (PyneComp)
2288
+ condition_parameter = _CONDITION_PARAMETERS.get(func) if isinstance(func, str) else None
2289
+ transformed_args = [self._condition(arg) if condition_parameter and index == 0 else self._transform_expression(arg)
2290
+ for index, arg in enumerate(call.args)]
2291
+ transformed_kwargs = {k: self._condition(v) if k == condition_parameter else self._transform_expression(v)
2292
+ for k, v in call.kwargs.items()}
2293
+ transformed_args, transformed_kwargs = self._overload_arguments.coerce(
2294
+ pine_call, pine_name, call.args, transformed_args, call.kwargs, transformed_kwargs)
2295
+ return FunctionCall(func=func, args=transformed_args, kwargs=transformed_kwargs)
2296
+
2297
+ def _transform_method_call(self, call: MethodCall) -> Union[MethodCall, FunctionCall, TernaryOp]:
2298
+ """Transform method call (Rule 48)."""
2299
+ if '.' in call.method: # zs.get(0).bx.delete() parses as method 'bx.delete' (#36)
2300
+ *field_names, method_name = call.method.split('.')
2301
+ receiver = call.object
2302
+ for field_name in field_names:
2303
+ receiver = MemberAccess(object=receiver, member=field_name)
2304
+ return self._transform_method_call(dataclasses.replace(call, object=receiver, method=method_name))
2305
+
2306
+ def transformed_receiver():
2307
+ return self._transform_expression(call.object)
2308
+
2309
+ # A user-defined @method, also on an expression: (expr).even_decimal() -> even_decimal(expr).
2310
+ # First, so a user method named like the special cases below still runs.
2311
+ if call.method in self.method_functions:
2312
+ routed = self._route_method_call(call, call.method, call.object, transformed_receiver)
2313
+ if routed is not None:
2314
+ return routed
2315
+
2316
+ # Special case: UDT.new() should become UDT() (remove .new())
2317
+ if call.method == 'new' and isinstance(call.object, Identifier):
2318
+ # Check if this is a UDT type (starts with uppercase)
2319
+ if call.object.name[0].isupper():
2320
+ # Convert to function call: Statistics.new() -> Statistics()
2321
+ args = [self._transform_expression(arg) for arg in call.args]
2322
+ kwargs = {k: self._transform_expression(v) for k, v in call.kwargs.items()}
2323
+ return FunctionCall(func=call.object.name, args=args, kwargs=kwargs)
2324
+
2325
+ # Special case: .copy() on UDT object fields should become udt_copy()
2326
+ # Example: this.lbl.copy() -> udt_copy(this.lbl); and on a call's result (`a.get(j).copy()`, PyneComp)
2327
+ # `x.copy()` is `udt_copy(x)` whatever `x` is, an array included (the reference never writes array.copy
2328
+ # for the method form: mlmi `distances.copy()`, forex_factory_utility `N.copy()`)
2329
+ if call.method == 'copy' and not call.args and 'copy' not in self._user_methods():
2330
+ obj_expr = self._transform_expression(call.object)
2331
+ args = [obj_expr] + [self._transform_expression(arg) for arg in call.args]
2332
+ kwargs = {k: self._transform_expression(v) for k, v in call.kwargs.items()}
2333
+ self.import_resolver.uses_udt_copy = True
2334
+ return FunctionCall(func='udt_copy', args=args, kwargs=kwargs)
2335
+
2336
+ # A builtin method: id.push(value) -> array.push(id, value)
2337
+ routed = self._route_method_call(call, call.method, call.object, transformed_receiver)
2338
+ if routed is not None:
2339
+ return routed
2340
+
2341
+ obj = self._transform_expression(call.object)
2342
+ args = [self._transform_expression(arg) for arg in call.args]
2343
+ kwargs = {k: self._transform_expression(v) for k, v in call.kwargs.items()}
2344
+
2345
+ return MethodCall(object=obj, method=call.method, args=args, kwargs=kwargs)
2346
+
2347
+ def _transform_member_access(self, expr: MemberAccess) -> MemberAccess:
2348
+ """Transform member access (Rules 23-25)."""
2349
+ # Handle module renames
2350
+ if isinstance(expr.object, str):
2351
+ obj = get_module_name(expr.object)
2352
+ else:
2353
+ obj = self._transform_expression(expr.object)
2354
+
2355
+ # Handle plot style remapping (Rule 25)
2356
+ if isinstance(expr.object, str):
2357
+ full_name = f"{expr.object}.{expr.member}"
2358
+ if needs_plot_style_remap(full_name):
2359
+ remapped = get_plot_style_remap(full_name)
2360
+ parts = remapped.split('.')
2361
+ return MemberAccess(object=parts[0], member=parts[1])
2362
+
2363
+ return MemberAccess(object=obj, member=expr.member)
2364
+
2365
+ def _transform_index_access(self, expr: IndexAccess) -> Expression:
2366
+ """Transform index access (Rule 52: warn about negative indices).
2367
+
2368
+ A group or a call as the object of a history index was already bound
2369
+ to a named local by grouped_history_lowering (card #32).
2370
+ """
2371
+ obj = self._transform_expression(expr.object)
2372
+ index = self._transform_expression(expr.index)
2373
+ if not isinstance(expr.object, (Identifier, MemberAccess)) and not (
2374
+ isinstance(expr.object, str)):
2375
+ # the history of an expression or a call: pynecore's inline_series(expr, n) (PyneComp)
2376
+ return FunctionCall(func='inline_series', args=[obj, index], kwargs={})
2377
+ return IndexAccess(object=obj, index=index)
2378
+
2379
+ def _transform_identifier(self, expr: Identifier) -> Identifier:
2380
+ """Transform identifier (Rule 23 for module prefixes)."""
2381
+ # Handle true/false conversion (Rule 17)
2382
+ if expr.name == 'true':
2383
+ return Literal(value=True, literal_type='bool')
2384
+ elif expr.name == 'false':
2385
+ return Literal(value=False, literal_type='bool')
2386
+
2387
+ return Identifier(name=expr.name)
2388
+
2389
+ def _transform_literal(self, expr: Literal) -> Literal:
2390
+ """Transform literal (Rule 17: true/false to True/False)."""
2391
+ if expr.literal_type == 'bool':
2392
+ # Ensure Python boolean values
2393
+ return Literal(value=bool(expr.value), literal_type='bool')
2394
+ return expr
2395
+
2396
+ def _na_initialiser(self, converted_type: Optional[str]) -> Expression:
2397
+ """The value of an `= na` initialiser: `na(T)` for a known type, else bare `na`.
2398
+
2399
+ Pine's na is typeless until a later assignment gives it one; PyneCore
2400
+ has the same value (bare `na` -> `lib._na_none`), which the transpiler
2401
+ already emits for `color(na)` and for-loop expression targets. Emitting
2402
+ `na(Any)` would reference a name nothing imports.
2403
+ """
2404
+ if not converted_type or converted_type == 'Any':
2405
+ return NaLiteral()
2406
+ return FunctionCall(func='na', args=[Identifier(name=converted_type)], kwargs={})
2407
+
2408
+ def _drawing_type_cast(self, cast_name: str, argument: Expression) -> Expression:
2409
+ """`box(na)` -> `NA(Box)`, an na of the declared type; `box(x)` -> `x` (card #8).
2410
+ Emitted as is, the cast would call the namespace module."""
2411
+ if not isinstance(argument, NaLiteral):
2412
+ return self._transform_expression(argument)
2413
+ type_name = get_type_name(cast_name)
2414
+ self.import_resolver.add_drawing_type(type_name)
2415
+ return FunctionCall(func='NA', args=[Identifier(name=type_name)], kwargs={})
2416
+
2417
+ def _transform_na_literal(self, expr: NaLiteral) -> Identifier:
2418
+ """Transform na literal to na identifier (Rule 19)."""
2419
+ # In PyneCore, 'na' is used directly, not NA()
2420
+ return Identifier(name='na')
2421
+
2422
+ def _extract_last_expression_from_block(self, block):
2423
+ """Extract the last expression from a block of statements.
2424
+ In Pine Script, the last evaluated expression in a block is the return value.
2425
+ """
2426
+ if isinstance(block, Expression):
2427
+ return self._transform_expression(block)
2428
+ last = block[-1] if isinstance(block, list) and block else block
2429
+ # If the last item is an expression statement (ExpressionStatement or raw Expression)
2430
+ if isinstance(last, Expression):
2431
+ return self._transform_expression(last)
2432
+ # lower_block_values rewrites every block it can; emitting only the tail
2433
+ # here would drop the block's statements (card #41) or the binding of a
2434
+ # sole-binding branch, and a bare loop or an empty branch would be
2435
+ # emitted as None (card #50)
2436
+ raise UnsupportedFeatureError(
2437
+ f'{type(last).__name__} as the value of an if/switch expression branch', getattr(last, 'line', None))
2438
+
2439
+ def _transform_if_expression(self, expr: IfExpression) -> TernaryOp:
2440
+ """Transform if expression to Python inline if/else (Rule 16)."""
2441
+ # if cond\n val1\nelse\n val2 => val1 if cond else val2
2442
+ condition = self._transform_expression(expr.condition)
2443
+
2444
+ # Handle single expression vs block
2445
+ true_expr = self._extract_last_expression_from_block(expr.true_expr)
2446
+
2447
+ if expr.false_expr:
2448
+ false_expr = self._extract_last_expression_from_block(expr.false_expr)
2449
+ elif expr.value_is_bool:
2450
+ # Pine v6: an untaken if of bool type is false, not na (cards #50, #56)
2451
+ false_expr = Literal(value=False, literal_type='bool')
2452
+ else:
2453
+ false_expr = self._untaken_na()
2454
+
2455
+ return TernaryOp(condition=condition, true_expr=true_expr, false_expr=false_expr)
2456
+
2457
+ def _untaken_na(self) -> NaLiteral:
2458
+ """The value of an if/switch expression whose arms do not run: Pine's na.
2459
+ Python's None is not na to pynecore (`na(None)` is falsy) (card #50)."""
2460
+ self.import_resolver.uses_na = True
2461
+ return NaLiteral()
2462
+
2463
+ def _transform_switch_expression(self, expr: SwitchExpression) -> Expression:
2464
+ """Transform switch expression to nested ternary (if-elif-else chain)."""
2465
+ # Transform the switch expression to a chain of ternary operators
2466
+ # switch expr
2467
+ # val1 => result1
2468
+ # val2 => result2
2469
+ # => default
2470
+ # Becomes: result1 if (expr == val1) else (result2 if (expr == val2) else default)
2471
+
2472
+ # Or for switch without expr (condition-based):
2473
+ # switch
2474
+ # cond1 => result1
2475
+ # cond2 => result2
2476
+ # => default
2477
+ # Becomes: result1 if cond1 else (result2 if cond2 else default)
2478
+
2479
+ if not expr.cases:
2480
+ # No cases, just return default
2481
+ if expr.default:
2482
+ return self._extract_last_expression_from_block(expr.default)
2483
+ return self._untaken_na()
2484
+
2485
+ # Conditions in case order: the first one binds the subject (card #43)
2486
+ conditions = self._switch_case_conditions(expr.expr, expr.cases)
2487
+
2488
+ # Build nested ternary from right to left
2489
+ result = None
2490
+ if expr.default:
2491
+ result = self._extract_last_expression_from_block(expr.default)
2492
+ elif expr.value_is_bool:
2493
+ # Pine v6: an untaken switch of bool type is false, not na (cards #50, #56)
2494
+ result = Literal(value=False, literal_type='bool')
2495
+ else:
2496
+ result = self._untaken_na()
2497
+
2498
+ # Process cases in reverse order to build nested ternary
2499
+ for condition, (_, case_value) in reversed(list(zip(conditions, expr.cases))):
2500
+ # Transform case value
2501
+ value_expr = self._extract_last_expression_from_block(case_value)
2502
+
2503
+ # Build ternary: value if condition else result
2504
+ result = TernaryOp(
2505
+ condition=condition,
2506
+ true_expr=value_expr,
2507
+ false_expr=result
2508
+ )
2509
+
2510
+ return result