@pineforge/codegen-pyodide 0.10.3 → 1.0.0-rc.1

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 (51) hide show
  1. package/README.md +16 -16
  2. package/glue.py +24 -16
  3. package/package.json +1 -1
  4. package/pineforge_codegen/__init__.py +125 -34
  5. package/pineforge_codegen/analyzer/__init__.py +2 -0
  6. package/pineforge_codegen/analyzer/base.py +767 -77
  7. package/pineforge_codegen/analyzer/call_handlers.py +268 -42
  8. package/pineforge_codegen/analyzer/contracts.py +37 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +30 -4
  10. package/pineforge_codegen/analyzer/tables.py +49 -8
  11. package/pineforge_codegen/analyzer/types.py +33 -1
  12. package/pineforge_codegen/ast_nodes.py +32 -1
  13. package/pineforge_codegen/block_locals.py +185 -0
  14. package/pineforge_codegen/builtin_keywords.py +42 -0
  15. package/pineforge_codegen/codegen/base.py +907 -159
  16. package/pineforge_codegen/codegen/constant_fold.py +131 -0
  17. package/pineforge_codegen/codegen/drawing.py +221 -79
  18. package/pineforge_codegen/codegen/emit_top.py +961 -213
  19. package/pineforge_codegen/codegen/helpers.py +435 -14
  20. package/pineforge_codegen/codegen/host_members.py +162 -0
  21. package/pineforge_codegen/codegen/input.py +252 -85
  22. package/pineforge_codegen/codegen/security.py +4372 -377
  23. package/pineforge_codegen/codegen/session_market.py +71 -0
  24. package/pineforge_codegen/codegen/ta.py +1198 -101
  25. package/pineforge_codegen/codegen/tables.py +205 -71
  26. package/pineforge_codegen/codegen/tv_number_format.py +270 -0
  27. package/pineforge_codegen/codegen/types.py +2055 -62
  28. package/pineforge_codegen/codegen/visit_call.py +929 -131
  29. package/pineforge_codegen/codegen/visit_expr.py +765 -63
  30. package/pineforge_codegen/codegen/visit_stmt.py +610 -52
  31. package/pineforge_codegen/external_requests.py +877 -0
  32. package/pineforge_codegen/lexer.py +104 -22
  33. package/pineforge_codegen/library_inline.py +1304 -0
  34. package/pineforge_codegen/library_modules.py +126 -0
  35. package/pineforge_codegen/library_v5.py +683 -0
  36. package/pineforge_codegen/limits.py +138 -0
  37. package/pineforge_codegen/method_binding.py +33 -0
  38. package/pineforge_codegen/parser.py +384 -68
  39. package/pineforge_codegen/pine_libraries.py +266 -0
  40. package/pineforge_codegen/pine_spelling.py +216 -0
  41. package/pineforge_codegen/pragmas.py +64 -10
  42. package/pineforge_codegen/security_contexts.py +1585 -0
  43. package/pineforge_codegen/session_reads.py +84 -0
  44. package/pineforge_codegen/signatures.py +48 -23
  45. package/pineforge_codegen/support_checker.py +1106 -85
  46. package/pineforge_codegen/symbols.py +4 -2
  47. package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
  48. package/release.json +2 -2
  49. package/tables.json +23 -21
  50. package/transpile.worker.mjs +24 -16
  51. package/pineforge_codegen-0.10.3.tar.gz +0 -0
@@ -0,0 +1,266 @@
1
+ """Where the sources of a script's imported Pine libraries come from.
2
+
3
+ ``transpile(source, libraries=...)`` takes them from the caller: a mapping of
4
+ import path (``user/name/version``) to library source text. With
5
+ ``libraries=None`` they come from the environment the campaign's case runner
6
+ sets (pineforge-workflow ``docs/xsym-requests.md``, "The environment
7
+ contract"):
8
+
9
+ - ``PINEFORGE_PINE_LIBRARIES=<dir>``: ``<dir>/libraries.json``
10
+ (``pineforge-pine-libraries/v1``) and ``<dir>/<user>/<name>/<version>.pine``,
11
+ the bytes pine-facade served. The directory is case-wide: it holds every
12
+ library any probe of the case pins.
13
+ - ``PINEFORGE_REQUESTS_ROOT=<dir>``: one ``<slug>/requests.json``
14
+ (``pineforge-probe-requests/v1``) per probe of the case that pins data, with
15
+ every file it references under ``<slug>/files/<sha256>``.
16
+
17
+ A script's imports resolve only through its own manifest, never through the
18
+ case-wide directory alone: otherwise a probe that pins no library would
19
+ transpile on a shard neighbour's pin, and measure differently by case
20
+ composition. ``transpile()`` is handed the source text, not a slug (the frozen
21
+ verifier, pineforge-lab 3bac0b7b ``scripts/verify-engine-local.py:1698``, calls
22
+ ``transpile((d / "strategy.pine").read_text())``), so the manifest is found by
23
+ content: the one ``requests.json`` whose ``probe.strategySha256`` is the sha256
24
+ of the script. That sha is of the strategy file's bytes, and ``read_text()``
25
+ folds ``\\r\\n`` and ``\\r`` line ends to ``\\n``: the text matches a manifest
26
+ when its UTF-8 bytes do, or the same bytes with every ``\\n`` spelled
27
+ ``\\r\\n``, or ``\\r``. A file mixing line ends matches none. No manifest, or
28
+ more than one, resolves no library: every import keeps its refusal. An import
29
+ the manifest does not pin is refused by name whatever ``libraries.json`` lists,
30
+ and each source is verified against the manifest entry's ``sha256``.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import json
37
+ import os
38
+ import re
39
+ from collections.abc import Mapping
40
+ from dataclasses import dataclass
41
+ from pathlib import Path
42
+
43
+ LIBRARIES_ENV = "PINEFORGE_PINE_LIBRARIES"
44
+ REQUESTS_ENV = "PINEFORGE_REQUESTS_ROOT"
45
+ LIBRARIES_SCHEMA = "pineforge-pine-libraries/v1"
46
+ REQUESTS_SCHEMA = "pineforge-probe-requests/v1"
47
+
48
+ _SHA256_RE = re.compile(r"[0-9a-f]{64}")
49
+
50
+
51
+ class LibraryResolveError(Exception):
52
+ """An import's library source cannot be used; the message names it."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class LibrarySource:
57
+ path: str
58
+ text: str
59
+ sha256: str | None
60
+
61
+
62
+ def source_digests(text: str) -> set[str]:
63
+ """sha256 of every byte form ``text`` can have been read from with
64
+ ``Path.read_text()``: its own UTF-8 bytes and, for a text holding no
65
+ ``\\r``, the same bytes with CRLF or CR line ends."""
66
+ forms = [text]
67
+ if "\r" not in text and "\n" in text:
68
+ forms += [text.replace("\n", "\r\n"), text.replace("\n", "\r")]
69
+ digests = set()
70
+ for form in forms:
71
+ try:
72
+ data = form.encode("utf-8", "surrogateescape")
73
+ except UnicodeEncodeError:
74
+ continue
75
+ digests.add(hashlib.sha256(data).hexdigest())
76
+ return digests
77
+
78
+
79
+ class LibraryResolver:
80
+ """The library sources one transpile may read.
81
+
82
+ ``configured`` is False when neither ``libraries=`` nor a requests
83
+ manifest of the script applies; every import then keeps today's
84
+ refusal, with ``reason`` (if any) saying why none applied.
85
+ """
86
+
87
+ configured = True
88
+ reason: str | None = None
89
+
90
+ def pinned(self, path: str) -> bool:
91
+ raise NotImplementedError
92
+
93
+ def load(self, path: str) -> LibrarySource:
94
+ raise NotImplementedError
95
+
96
+
97
+ class _Unconfigured(LibraryResolver):
98
+ configured = False
99
+
100
+ def __init__(self, reason: str | None = None) -> None:
101
+ self.reason = reason
102
+
103
+ def pinned(self, path: str) -> bool:
104
+ return False
105
+
106
+ def load(self, path: str) -> LibrarySource:
107
+ raise LibraryResolveError(self.reason or "no library sources are configured")
108
+
109
+
110
+ class _Provided(LibraryResolver):
111
+ """``transpile(..., libraries={path: source})``: the caller's sources."""
112
+
113
+ def __init__(self, sources: Mapping[str, str]) -> None:
114
+ self._sources: dict[str, str] = {}
115
+ for path, text in sources.items():
116
+ if isinstance(text, (bytes, bytearray)):
117
+ text = bytes(text).decode("utf-8")
118
+ if not isinstance(text, str):
119
+ raise TypeError(
120
+ f"libraries[{path!r}] must be the library's source text")
121
+ self._sources[str(path)] = text
122
+
123
+ def pinned(self, path: str) -> bool:
124
+ return path in self._sources
125
+
126
+ def load(self, path: str) -> LibrarySource:
127
+ if path not in self._sources:
128
+ raise LibraryResolveError(
129
+ f"library '{path}' is not among the libraries passed to transpile()")
130
+ text = self._sources[path]
131
+ return LibrarySource(path, text, None)
132
+
133
+
134
+ class _Pinned(LibraryResolver):
135
+ """The libraries one probe's requests manifest pins."""
136
+
137
+ def __init__(self, library_dir: Path, requests_root: Path, slug_dir: Path,
138
+ manifest: dict) -> None:
139
+ self._library_dir = library_dir
140
+ self._requests_root = requests_root
141
+ self._slug_dir = slug_dir
142
+ self._where = f"{slug_dir.name}/requests.json"
143
+ self._pins: dict[str, dict] = {}
144
+ for entry in manifest.get("libraries") or []:
145
+ if isinstance(entry, dict) and isinstance(entry.get("import"), str):
146
+ self._pins.setdefault(entry["import"], entry)
147
+ self._index: dict | None = None
148
+
149
+ def pinned(self, path: str) -> bool:
150
+ return path in self._pins
151
+
152
+ def _library_index(self) -> dict:
153
+ if self._index is None:
154
+ self._index = {}
155
+ try:
156
+ doc = json.loads((self._library_dir / "libraries.json")
157
+ .read_text(encoding="utf-8"))
158
+ except (OSError, ValueError):
159
+ doc = None
160
+ if (isinstance(doc, dict)
161
+ and doc.get("schemaVersion", LIBRARIES_SCHEMA) == LIBRARIES_SCHEMA
162
+ and isinstance(doc.get("libraries"), dict)):
163
+ self._index = {k: v for k, v in doc["libraries"].items()
164
+ if isinstance(v, dict)}
165
+ return self._index
166
+
167
+ def load(self, path: str) -> LibrarySource:
168
+ entry = self._pins.get(path)
169
+ if entry is None:
170
+ raise LibraryResolveError(
171
+ f"library '{path}' is not pinned by this script's requests "
172
+ f"manifest ({self._where})")
173
+ access = entry.get("access")
174
+ if not (isinstance(access, str) and access.startswith("open")):
175
+ raise LibraryResolveError(
176
+ f"library '{path}' is not open-source (access {access!r}); "
177
+ "PineForge inlines open libraries only")
178
+ sha = entry.get("sha256")
179
+ if not (isinstance(sha, str) and _SHA256_RE.fullmatch(sha)):
180
+ raise LibraryResolveError(
181
+ f"library '{path}' has no valid sha256 in {self._where}")
182
+ indexed = self._library_index().get(path) or {}
183
+ indexed_access = indexed.get("access")
184
+ if indexed_access is not None and not (
185
+ isinstance(indexed_access, str) and indexed_access.startswith("open")):
186
+ raise LibraryResolveError(
187
+ f"library '{path}' is not open-source (access {indexed_access!r} "
188
+ "in libraries.json); PineForge inlines open libraries only")
189
+ candidates = [self._slug_dir / "files" / sha]
190
+ rel = indexed.get("file")
191
+ if isinstance(rel, str) and rel and not Path(rel).is_absolute() \
192
+ and ".." not in Path(rel).parts:
193
+ candidates.append(self._library_dir / rel)
194
+ candidates.append(self._library_dir / f"{path}.pine")
195
+ seen: list[str] = []
196
+ for candidate in dict.fromkeys(candidates):
197
+ try:
198
+ data = candidate.read_bytes()
199
+ except OSError:
200
+ continue
201
+ got = hashlib.sha256(data).hexdigest()
202
+ if got != sha:
203
+ seen.append(got)
204
+ continue
205
+ try:
206
+ text = data.decode("utf-8")
207
+ except UnicodeDecodeError as exc:
208
+ raise LibraryResolveError(
209
+ f"library '{path}' source is not UTF-8 text") from exc
210
+ return LibrarySource(path, text, sha)
211
+ if seen:
212
+ raise LibraryResolveError(
213
+ f"library '{path}' source does not match its pinned sha256 "
214
+ f"{sha[:12]}... (found {seen[0][:12]}...)")
215
+ raise LibraryResolveError(
216
+ f"library '{path}' source is missing (sha256 {sha[:12]}... under "
217
+ f"${REQUESTS_ENV}/{self._slug_dir.name}/files and ${LIBRARIES_ENV})")
218
+
219
+
220
+ def _manifest_for(text: str, requests_root: Path) -> tuple[Path | None, dict | None, str]:
221
+ """The one requests manifest under ``requests_root`` pinning ``text``."""
222
+ digests = source_digests(text)
223
+ matches: list[tuple[Path, dict]] = []
224
+ try:
225
+ candidates = sorted(requests_root.glob("*/requests.json"))
226
+ except OSError:
227
+ candidates = []
228
+ for path in candidates:
229
+ try:
230
+ doc = json.loads(path.read_text(encoding="utf-8"))
231
+ except (OSError, ValueError):
232
+ continue
233
+ if not isinstance(doc, dict) or doc.get("schemaVersion") != REQUESTS_SCHEMA:
234
+ continue
235
+ probe = doc.get("probe")
236
+ sha = probe.get("strategySha256") if isinstance(probe, dict) else None
237
+ if isinstance(sha, str) and sha.lower() in digests:
238
+ matches.append((path.parent, doc))
239
+ if len(matches) == 1:
240
+ return matches[0][0], matches[0][1], ""
241
+ if not matches:
242
+ return None, None, (
243
+ f"no requests manifest under ${REQUESTS_ENV} pins this script's "
244
+ "source")
245
+ slugs = ", ".join(sorted(p.name for p, _ in matches))
246
+ return None, None, (
247
+ f"{len(matches)} requests manifests under ${REQUESTS_ENV} pin this "
248
+ f"script's source ({slugs})")
249
+
250
+
251
+ def library_resolver(source: str, libraries: Mapping[str, str] | None) -> LibraryResolver:
252
+ """The library sources ``transpile(source, libraries=libraries)`` reads."""
253
+ if libraries is not None:
254
+ return _Provided(libraries)
255
+ library_dir = os.environ.get(LIBRARIES_ENV)
256
+ if not library_dir:
257
+ return _Unconfigured()
258
+ requests_root = os.environ.get(REQUESTS_ENV)
259
+ if not requests_root:
260
+ return _Unconfigured(
261
+ f"${LIBRARIES_ENV} is set but ${REQUESTS_ENV} is not, so no "
262
+ "requests manifest pins this script's libraries")
263
+ slug_dir, manifest, reason = _manifest_for(source, Path(requests_root))
264
+ if manifest is None:
265
+ return _Unconfigured(reason)
266
+ return _Pinned(Path(library_dir), Path(requests_root), slug_dir, manifest)
@@ -0,0 +1,216 @@
1
+ """Pine source spellings that the analyzer hands the codegen as strings.
2
+
3
+ TA constructor arguments (``TACallSite.ctor_args``), user-function call-site
4
+ arguments and class-scope derived lengths travel as Pine source text that the
5
+ codegen scans for identifiers, substitutes into, folds and re-parses. An
6
+ inline ``input.*()`` call is a legitimate leaf of such an expression
7
+ (``ta.ema(close, input.int(9, "fast"))``), and its own argument text -- the
8
+ title string, keyword names, an ``options`` list -- is input metadata, not
9
+ part of the expression. These helpers keep string literals intact and let a
10
+ caller treat each inline input call as one leaf.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import re
17
+ from typing import Callable, Iterator
18
+
19
+ from .ast_nodes import (
20
+ ASTNode, BinOp, BoolLiteral, FuncCall, FuncDef, Identifier, MemberAccess,
21
+ MethodDef, NaLiteral, NumberLiteral, StringLiteral, Subscript, Ternary,
22
+ TupleLiteral, UnaryOp, VarDecl,
23
+ )
24
+
25
+ _STRING_OR_IDENT = re.compile(
26
+ r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|[A-Za-z_][A-Za-z_0-9]*'
27
+ )
28
+
29
+
30
+ def is_input_call(node) -> bool:
31
+ """``input(...)`` or ``input.<type>(...)``, judged on the spelling alone."""
32
+ if not isinstance(node, FuncCall):
33
+ return False
34
+ callee = node.callee
35
+ if isinstance(callee, Identifier):
36
+ return callee.name == "input"
37
+ return (isinstance(callee, MemberAccess)
38
+ and isinstance(callee.object, Identifier)
39
+ and callee.object.name == "input")
40
+
41
+
42
+ def pine_string_literal(value: str) -> str:
43
+ """The double-quoted literal the lexer reads back as ``value``."""
44
+ return '"' + (value.replace("\\", "\\\\").replace('"', '\\"')
45
+ .replace("\n", "\\n").replace("\t", "\\t")) + '"'
46
+
47
+
48
+ def spell_input_call(node: FuncCall, title: str | None = None) -> str | None:
49
+ """An input call spelled with every argument, keywords included, so the
50
+ text re-parses to the same call. None when an argument is not one of the
51
+ constant shapes an input takes (literal, name, ``display.none``-style
52
+ member, signed number, ``options`` list).
53
+
54
+ ``title`` spells an untitled call's key as its ``title=`` argument: the
55
+ re-parsed call has lost the declaration that named it."""
56
+ parts = [_spell_input_arg(a) for a in node.args]
57
+ parts += [None if (v := _spell_input_arg(value)) is None else f"{key}={v}"
58
+ for key, value in node.kwargs.items()]
59
+ if title is not None:
60
+ parts.append(f"title={pine_string_literal(title)}")
61
+ callee = _spell_input_arg(node.callee)
62
+ if callee is None or None in parts:
63
+ return None
64
+ return f"{callee}({', '.join(parts)})"
65
+
66
+
67
+ def _spell_input_arg(node) -> str | None:
68
+ if isinstance(node, NumberLiteral):
69
+ return str(node.value)
70
+ if isinstance(node, StringLiteral):
71
+ return pine_string_literal(node.value)
72
+ if isinstance(node, BoolLiteral):
73
+ return "true" if node.value else "false"
74
+ if isinstance(node, NaLiteral):
75
+ return "na"
76
+ if isinstance(node, Identifier):
77
+ return node.name
78
+ if isinstance(node, MemberAccess):
79
+ obj = _spell_input_arg(node.object)
80
+ return None if obj is None else f"{obj}.{node.member}"
81
+ if isinstance(node, UnaryOp) and node.op in ("-", "+"):
82
+ operand = _spell_input_arg(node.operand)
83
+ return None if operand is None else f"{node.op}{operand}"
84
+ if isinstance(node, TupleLiteral):
85
+ elems = [_spell_input_arg(e) for e in node.elements]
86
+ return None if None in elems else "[" + ", ".join(elems) + "]"
87
+ return None
88
+
89
+
90
+ def expr_start(node: ASTNode) -> ASTNode:
91
+ """The leftmost sub-node of an expression, whose location is the
92
+ expression's first token (a call's own location is its ``(``)."""
93
+ while True:
94
+ if isinstance(node, FuncCall):
95
+ child = node.callee
96
+ elif isinstance(node, (MemberAccess, Subscript)):
97
+ child = node.object
98
+ elif isinstance(node, BinOp):
99
+ child = node.left
100
+ elif isinstance(node, Ternary):
101
+ child = node.condition
102
+ else:
103
+ return node
104
+ if getattr(child, "loc", None) is None:
105
+ return node
106
+ node = child
107
+
108
+
109
+ # Statement fields holding a local block. Pine declares script inputs at
110
+ # global scope only.
111
+ _LOCAL_SCOPE_FIELDS = frozenset({"body", "else_body", "default_body"})
112
+
113
+
114
+ def global_input_calls(node) -> Iterator[FuncCall]:
115
+ """The input calls ``node`` makes at global scope, in source order -- not
116
+ inside if/for/while/switch blocks or callable bodies."""
117
+ if isinstance(node, list):
118
+ for item in node:
119
+ yield from global_input_calls(item)
120
+ return
121
+ if not isinstance(node, ASTNode) or isinstance(node, (FuncDef, MethodDef)):
122
+ return
123
+ if is_input_call(node):
124
+ yield node
125
+ return
126
+ for f in dataclasses.fields(node):
127
+ if f.name in ("loc", "annotations") or f.name in _LOCAL_SCOPE_FIELDS:
128
+ continue
129
+ value = getattr(node, f.name)
130
+ if f.name == "cases":
131
+ value = [case_expr for case_expr, _stmts in value]
132
+ elif isinstance(value, dict):
133
+ value = list(value.values())
134
+ yield from global_input_calls(value)
135
+
136
+
137
+ def input_binding_names(body) -> dict[int, str]:
138
+ """``id(call) -> name`` for every global-scope input call a declaration
139
+ holds: bound straight to ``name = input.*()`` or nested anywhere in the
140
+ declaration's value (``n = input.int(9) * 2``, ``x = ta.ema(close,
141
+ input.int(9))``, a ``var`` initializer). TradingView keys an input with no
142
+ title by that variable name ("If not specified, the variable name is used
143
+ as the input's title"), and so does every PineForge getter and the
144
+ manifest."""
145
+ names: dict[int, str] = {}
146
+ for stmt in body or []:
147
+ if not isinstance(stmt, VarDecl):
148
+ continue
149
+ for node in global_input_calls(stmt):
150
+ names[id(node)] = stmt.name
151
+ return names
152
+
153
+
154
+ def blank_string_literals(text: str) -> str:
155
+ """``text`` with every string literal emptied, so an identifier scan
156
+ never reads a literal's contents (``mode == "Fast"``) as a name."""
157
+ def _one(match: re.Match) -> str:
158
+ token = match.group(0)
159
+ return token[0] * 2 if token[0] in "\"'" else token
160
+ return _STRING_OR_IDENT.sub(_one, text)
161
+
162
+
163
+ def sub_identifiers(text: str, repl: Callable[[re.Match], str]) -> str:
164
+ """``re.sub`` over identifier tokens only; string literals pass through."""
165
+ def _one(match: re.Match) -> str:
166
+ if match.group(0)[0] in "\"'":
167
+ return match.group(0)
168
+ return repl(match)
169
+ return _STRING_OR_IDENT.sub(_one, text)
170
+
171
+
172
+ def input_call_spans(text: str) -> list[tuple[int, int]]:
173
+ """``(start, end)`` of each ``input(...)`` / ``input.<type>(...)`` call in
174
+ ``text``. Parentheses inside string literals do not count, and a call
175
+ nested in another input call's arguments is part of the outer span."""
176
+ spans: list[tuple[int, int]] = []
177
+ if "input" not in text:
178
+ return spans
179
+ for match in _STRING_OR_IDENT.finditer(text):
180
+ start = match.start()
181
+ if match.group(0) != "input" or (spans and start < spans[-1][1]):
182
+ continue
183
+ if start > 0 and text[start - 1] == ".":
184
+ continue # a member named ``input``, not the namespace
185
+ pos = match.end()
186
+ member = re.match(r"\.[A-Za-z_][A-Za-z_0-9]*", text[pos:])
187
+ if member is not None:
188
+ pos += member.end()
189
+ if pos >= len(text) or text[pos] != "(":
190
+ continue
191
+ end = _matching_paren(text, pos)
192
+ if end is not None:
193
+ spans.append((start, end))
194
+ return spans
195
+
196
+
197
+ def _matching_paren(text: str, open_pos: int) -> int | None:
198
+ """Index just past the ``)`` closing the ``(`` at ``open_pos``."""
199
+ depth = 0
200
+ pos = open_pos
201
+ while pos < len(text):
202
+ ch = text[pos]
203
+ if ch in "\"'":
204
+ literal = _STRING_OR_IDENT.match(text, pos)
205
+ if literal is None:
206
+ return None # unterminated string literal
207
+ pos = literal.end()
208
+ continue
209
+ if ch == "(":
210
+ depth += 1
211
+ elif ch == ")":
212
+ depth -= 1
213
+ if depth == 0:
214
+ return pos + 1
215
+ pos += 1
216
+ return None
@@ -14,7 +14,10 @@ Why a pre-pass?
14
14
  :class:`Parser` machinery used for normal Pine expressions. This
15
15
  keeps a single source of truth for Pine syntax and ensures pragma
16
16
  expressions support the full grammar (logical operators, member
17
- access, function calls, ternaries, ...).
17
+ access, function calls, ternaries, ...). A ``ta.*`` call inside a
18
+ pragma is not computed, though: no TA state is allocated for it, so
19
+ the codegen renders it as ``na`` with an ``/* unsupported */`` marker.
20
+ Trace a script variable that holds the ``ta.*`` value instead.
18
21
 
19
22
  Pragma syntax (kept deliberately strict so unrelated comments are
20
23
  untouched)::
@@ -40,12 +43,14 @@ import re
40
43
  from dataclasses import dataclass
41
44
  from typing import Any
42
45
 
46
+ from .errors import CompileError, SourceLocation
43
47
  from .lexer import Lexer
44
- from .parser import Parser
48
+ from .limits import TimeBudget, check_ast_depth
49
+ from .parser import ParseError, Parser
45
50
 
46
51
 
47
- # Anchored to start/end of line so a stray ``// @pf-trace`` substring
48
- # inside a string literal or block comment cannot match. ``\s+`` after
52
+ # Anchored to start/end of line; lexical string spans are excluded below.
53
+ # ``\s+`` after
49
54
  # ``//`` requires at least one space before ``@pf-trace`` (the spec is
50
55
  # ``// @pf-trace ``, distinct from Pine's ``//@version=N``).
51
56
  _PRAGMA_RE = re.compile(
@@ -53,6 +58,27 @@ _PRAGMA_RE = re.compile(
53
58
  )
54
59
 
55
60
 
61
+ class _StringSpanLexer(Lexer):
62
+ """Use the Pine lexer itself to locate lines inside string literals."""
63
+
64
+ def __init__(self, source: str, filename: str = "<input>",
65
+ budget: TimeBudget | None = None) -> None:
66
+ super().__init__(source, filename=filename, budget=budget)
67
+ self.string_lines: set[int] = set()
68
+
69
+ def _record_string_lines(self, start_line: int) -> None:
70
+ if self.line > start_line:
71
+ self.string_lines.update(range(start_line + 1, self.line + 1))
72
+
73
+ def _read_multiline(self, quote: str, start_line: int, start_col: int) -> None:
74
+ super()._read_multiline(quote, start_line, start_col)
75
+ self._record_string_lines(start_line)
76
+
77
+ def _read_quoted(self, quote: str, start_line: int, start_col: int) -> None:
78
+ super()._read_quoted(quote, start_line, start_col)
79
+ self._record_string_lines(start_line)
80
+
81
+
56
82
  @dataclass
57
83
  class PfTracePragma:
58
84
  """One ``// @pf-trace name=expr`` annotation extracted from Pine source.
@@ -75,7 +101,8 @@ class PfTracePragma:
75
101
  line: int
76
102
 
77
103
 
78
- def extract_pf_trace_pragmas(source: str) -> list[PfTracePragma]:
104
+ def extract_pf_trace_pragmas(source: str, *, filename: str = "<input>",
105
+ budget: TimeBudget | None = None) -> list[PfTracePragma]:
79
106
  """Scan ``source`` for ``// @pf-trace`` line comments.
80
107
 
81
108
  Returns the pragmas in source order. The expression on the
@@ -93,10 +120,22 @@ def extract_pf_trace_pragmas(source: str) -> list[PfTracePragma]:
93
120
  scripts) — callers should treat this as the zero-overhead
94
121
  path.
95
122
  """
123
+ candidates = [(lineno, match)
124
+ for lineno, raw in enumerate(source.splitlines(), start=1)
125
+ if (match := _PRAGMA_RE.match(raw)) is not None]
126
+ if not candidates:
127
+ return []
128
+ lexer = _StringSpanLexer(source, filename=filename, budget=budget)
129
+ try:
130
+ lexer.tokenize()
131
+ except CompileError:
132
+ # This lexical pass only finds string spans. The main Lexer run owns
133
+ # syntax diagnostics; extraction itself has historically accepted
134
+ # arbitrary source text, including malformed block comments.
135
+ pass
96
136
  pragmas: list[PfTracePragma] = []
97
- for lineno, raw in enumerate(source.splitlines(), start=1):
98
- m = _PRAGMA_RE.match(raw)
99
- if not m:
137
+ for lineno, m in candidates:
138
+ if lineno in lexer.string_lines:
100
139
  continue
101
140
  name = m.group(1)
102
141
  expr_source = m.group(2)
@@ -104,8 +143,23 @@ def extract_pf_trace_pragmas(source: str) -> list[PfTracePragma]:
104
143
  # the expression body in isolation. ``Parser._parse_expression``
105
144
  # is the same entry the statement parser uses for RHS values,
106
145
  # so anything legal in ``x = <expr>`` is legal here.
107
- tokens = Lexer(expr_source).tokenize()
108
- expr_node = Parser(tokens, source=expr_source)._parse_expression()
146
+ try:
147
+ tokens = Lexer(expr_source, filename=filename, budget=budget).tokenize()
148
+ parser = Parser(tokens, source=expr_source, filename=filename,
149
+ budget=budget)
150
+ try:
151
+ expr_node = parser._parse_expression()
152
+ parser._expect_statement_end()
153
+ except ParseError as error:
154
+ parser._raise_syntax_error(error)
155
+ check_ast_depth(expr_node, filename)
156
+ except CompileError as exc:
157
+ for diagnostic in exc.diagnostics:
158
+ loc = diagnostic.location
159
+ diagnostic.location = SourceLocation(
160
+ filename, loc.line + lineno - 1, loc.col, loc.end_col,
161
+ )
162
+ raise CompileError(exc.diagnostics) from exc
109
163
  pragmas.append(
110
164
  PfTracePragma(
111
165
  name=name,