pineforge-codegen 0.6.5__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.
- pineforge_codegen/__init__.py +53 -0
- pineforge_codegen/analyzer/__init__.py +60 -0
- pineforge_codegen/analyzer/base.py +1563 -0
- pineforge_codegen/analyzer/call_handlers.py +895 -0
- pineforge_codegen/analyzer/contracts.py +163 -0
- pineforge_codegen/analyzer/diagnostics.py +118 -0
- pineforge_codegen/analyzer/tables.py +204 -0
- pineforge_codegen/analyzer/types.py +250 -0
- pineforge_codegen/ast_nodes.py +293 -0
- pineforge_codegen/codegen/__init__.py +78 -0
- pineforge_codegen/codegen/base.py +1381 -0
- pineforge_codegen/codegen/emit_top.py +875 -0
- pineforge_codegen/codegen/helpers.py +163 -0
- pineforge_codegen/codegen/helpers_syminfo.py +134 -0
- pineforge_codegen/codegen/input.py +189 -0
- pineforge_codegen/codegen/security.py +1564 -0
- pineforge_codegen/codegen/ta.py +298 -0
- pineforge_codegen/codegen/tables.py +613 -0
- pineforge_codegen/codegen/types.py +573 -0
- pineforge_codegen/codegen/visit_call.py +1305 -0
- pineforge_codegen/codegen/visit_expr.py +701 -0
- pineforge_codegen/codegen/visit_stmt.py +729 -0
- pineforge_codegen/errors.py +98 -0
- pineforge_codegen/lexer.py +531 -0
- pineforge_codegen/parser.py +1198 -0
- pineforge_codegen/pragmas.py +117 -0
- pineforge_codegen/signatures.py +808 -0
- pineforge_codegen/support_checker.py +1111 -0
- pineforge_codegen/symbols.py +118 -0
- pineforge_codegen/tokens.py +406 -0
- pineforge_codegen/tv_input_choices.py +86 -0
- pineforge_codegen-0.6.5.dist-info/METADATA +462 -0
- pineforge_codegen-0.6.5.dist-info/RECORD +35 -0
- pineforge_codegen-0.6.5.dist-info/WHEEL +4 -0
- pineforge_codegen-0.6.5.dist-info/licenses/LICENSE +197 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
from .errors import SourceLocation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PineType(Enum):
|
|
9
|
+
INT = "int"
|
|
10
|
+
FLOAT = "float"
|
|
11
|
+
BOOL = "bool"
|
|
12
|
+
STRING = "string"
|
|
13
|
+
COLOR = "color"
|
|
14
|
+
VOID = "void"
|
|
15
|
+
NA = "na"
|
|
16
|
+
UNKNOWN = "unknown"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class TypeSpec:
|
|
21
|
+
"""Structured Pine type metadata for collections and UDTs.
|
|
22
|
+
|
|
23
|
+
``PineType`` intentionally stays small because much of the older analyzer
|
|
24
|
+
expects primitive enum values. TypeSpec carries the extra shape needed by
|
|
25
|
+
codegen and diagnostics without forcing a broad type-system rewrite.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
kind: str
|
|
29
|
+
name: str | None = None
|
|
30
|
+
element: TypeSpec | None = None
|
|
31
|
+
key: TypeSpec | None = None
|
|
32
|
+
value: TypeSpec | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def primitive(cls, name: str) -> TypeSpec:
|
|
36
|
+
return cls(kind="primitive", name=name)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def udt(cls, name: str) -> TypeSpec:
|
|
40
|
+
return cls(kind="udt", name=name)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def array(cls, element: TypeSpec) -> TypeSpec:
|
|
44
|
+
return cls(kind="array", element=element)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def map(cls, key: TypeSpec, value: TypeSpec) -> TypeSpec:
|
|
48
|
+
return cls(kind="map", key=key, value=value)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def matrix(cls, element: TypeSpec) -> TypeSpec:
|
|
52
|
+
return cls(kind="matrix", element=element)
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
if self.kind == "array" and self.element is not None:
|
|
56
|
+
return f"array<{self.element}>"
|
|
57
|
+
if self.kind == "map" and self.key is not None and self.value is not None:
|
|
58
|
+
return f"map<{self.key},{self.value}>"
|
|
59
|
+
return self.name or self.kind
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Symbol:
|
|
64
|
+
name: str
|
|
65
|
+
pine_type: PineType
|
|
66
|
+
is_series: bool
|
|
67
|
+
is_var: bool
|
|
68
|
+
is_const: bool
|
|
69
|
+
const_value: Any
|
|
70
|
+
scope: str
|
|
71
|
+
loc: SourceLocation
|
|
72
|
+
# User-defined enum: Pine `enum` is a derived type (not a primitive). We still
|
|
73
|
+
# use PineType.INT for variables holding the selected member’s ordinal in C++.
|
|
74
|
+
# enum_type_name links to the enum’s name so str.tostring / field payloads work.
|
|
75
|
+
enum_type_name: str | None = None
|
|
76
|
+
# User-defined type: variable holds an instance of this UDT (from TypeName.new(...)).
|
|
77
|
+
udt_type_name: str | None = None
|
|
78
|
+
# Structured collection/UDT type, when a primitive PineType is insufficient.
|
|
79
|
+
type_spec: TypeSpec | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Scope:
|
|
83
|
+
def __init__(self, name: str, parent: Scope | None):
|
|
84
|
+
self.name = name
|
|
85
|
+
self.parent = parent
|
|
86
|
+
self.symbols: dict[str, Symbol] = {}
|
|
87
|
+
|
|
88
|
+
def define(self, symbol: Symbol) -> None:
|
|
89
|
+
self.symbols[symbol.name] = symbol
|
|
90
|
+
|
|
91
|
+
def resolve(self, name: str) -> Symbol | None:
|
|
92
|
+
if name in self.symbols:
|
|
93
|
+
return self.symbols[name]
|
|
94
|
+
if self.parent is not None:
|
|
95
|
+
return self.parent.resolve(name)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SymbolTable:
|
|
100
|
+
def __init__(self):
|
|
101
|
+
self.global_scope = Scope(name="global", parent=None)
|
|
102
|
+
self.current_scope = self.global_scope
|
|
103
|
+
self.all_scopes: list[Scope] = [self.global_scope]
|
|
104
|
+
|
|
105
|
+
def enter_scope(self, name: str) -> None:
|
|
106
|
+
new_scope = Scope(name=name, parent=self.current_scope)
|
|
107
|
+
self.all_scopes.append(new_scope)
|
|
108
|
+
self.current_scope = new_scope
|
|
109
|
+
|
|
110
|
+
def exit_scope(self) -> None:
|
|
111
|
+
if self.current_scope.parent is not None:
|
|
112
|
+
self.current_scope = self.current_scope.parent
|
|
113
|
+
|
|
114
|
+
def define(self, symbol: Symbol) -> None:
|
|
115
|
+
self.current_scope.define(symbol)
|
|
116
|
+
|
|
117
|
+
def resolve(self, name: str) -> Symbol | None:
|
|
118
|
+
return self.current_scope.resolve(name)
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Tokenizer for PineScript v6 source code."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TokenType(Enum):
|
|
8
|
+
# Literals
|
|
9
|
+
NUMBER = auto()
|
|
10
|
+
STRING = auto()
|
|
11
|
+
IDENT = auto()
|
|
12
|
+
|
|
13
|
+
# Structure
|
|
14
|
+
NEWLINE = auto()
|
|
15
|
+
INDENT = auto()
|
|
16
|
+
DEDENT = auto()
|
|
17
|
+
EOF_TOKEN = auto()
|
|
18
|
+
|
|
19
|
+
# Delimiters
|
|
20
|
+
LPAREN = auto()
|
|
21
|
+
RPAREN = auto()
|
|
22
|
+
LBRACKET = auto()
|
|
23
|
+
RBRACKET = auto()
|
|
24
|
+
COMMA = auto()
|
|
25
|
+
DOT = auto()
|
|
26
|
+
|
|
27
|
+
# Assignment
|
|
28
|
+
EQUALS = auto()
|
|
29
|
+
COLON_EQUALS = auto()
|
|
30
|
+
PLUS_EQUALS = auto()
|
|
31
|
+
MINUS_EQUALS = auto()
|
|
32
|
+
STAR_EQUALS = auto()
|
|
33
|
+
SLASH_EQUALS = auto()
|
|
34
|
+
|
|
35
|
+
# Arithmetic
|
|
36
|
+
PLUS = auto()
|
|
37
|
+
MINUS = auto()
|
|
38
|
+
STAR = auto()
|
|
39
|
+
SLASH = auto()
|
|
40
|
+
PERCENT = auto()
|
|
41
|
+
|
|
42
|
+
# Comparison
|
|
43
|
+
EQEQ = auto() # ==
|
|
44
|
+
NOTEQ = auto() # !=
|
|
45
|
+
GT = auto() # >
|
|
46
|
+
LT = auto() # <
|
|
47
|
+
GE = auto() # >=
|
|
48
|
+
LE = auto() # <=
|
|
49
|
+
|
|
50
|
+
# Logical (keywords)
|
|
51
|
+
AND = auto()
|
|
52
|
+
OR = auto()
|
|
53
|
+
NOT = auto()
|
|
54
|
+
|
|
55
|
+
# Ternary
|
|
56
|
+
QUESTION = auto()
|
|
57
|
+
COLON = auto()
|
|
58
|
+
|
|
59
|
+
# Arrow
|
|
60
|
+
FAT_ARROW = auto() # =>
|
|
61
|
+
|
|
62
|
+
# Keywords
|
|
63
|
+
IF = auto()
|
|
64
|
+
ELSE = auto()
|
|
65
|
+
FOR = auto()
|
|
66
|
+
WHILE = auto()
|
|
67
|
+
SWITCH = auto()
|
|
68
|
+
BREAK = auto()
|
|
69
|
+
CONTINUE = auto()
|
|
70
|
+
VAR = auto()
|
|
71
|
+
VARIP = auto()
|
|
72
|
+
TO = auto()
|
|
73
|
+
BY = auto()
|
|
74
|
+
TRUE = auto()
|
|
75
|
+
FALSE = auto()
|
|
76
|
+
NA = auto()
|
|
77
|
+
|
|
78
|
+
# Color literal (#rrggbb / #rrggbbaa)
|
|
79
|
+
COLOR = auto()
|
|
80
|
+
|
|
81
|
+
# Type keywords
|
|
82
|
+
TYPE_INT = auto()
|
|
83
|
+
TYPE_FLOAT = auto()
|
|
84
|
+
TYPE_BOOL = auto()
|
|
85
|
+
TYPE_STRING = auto()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Token:
|
|
90
|
+
type: TokenType
|
|
91
|
+
value: str
|
|
92
|
+
line: int
|
|
93
|
+
col: int
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
return f"Token({self.type.name}, {self.value!r}, L{self.line}:{self.col})"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
KEYWORDS = {
|
|
100
|
+
"if": TokenType.IF,
|
|
101
|
+
"else": TokenType.ELSE,
|
|
102
|
+
"for": TokenType.FOR,
|
|
103
|
+
"while": TokenType.WHILE,
|
|
104
|
+
"switch": TokenType.SWITCH,
|
|
105
|
+
"break": TokenType.BREAK,
|
|
106
|
+
"continue": TokenType.CONTINUE,
|
|
107
|
+
"var": TokenType.VAR,
|
|
108
|
+
"varip": TokenType.VARIP,
|
|
109
|
+
"to": TokenType.TO,
|
|
110
|
+
"by": TokenType.BY,
|
|
111
|
+
"not": TokenType.NOT,
|
|
112
|
+
"and": TokenType.AND,
|
|
113
|
+
"or": TokenType.OR,
|
|
114
|
+
"true": TokenType.TRUE,
|
|
115
|
+
"false": TokenType.FALSE,
|
|
116
|
+
"na": TokenType.NA,
|
|
117
|
+
"int": TokenType.TYPE_INT,
|
|
118
|
+
"float": TokenType.TYPE_FLOAT,
|
|
119
|
+
"bool": TokenType.TYPE_BOOL,
|
|
120
|
+
"string": TokenType.TYPE_STRING,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Tokenizer:
|
|
125
|
+
"""Converts PineScript v6 source string into a list of tokens."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, source: str) -> None:
|
|
128
|
+
self.source = source
|
|
129
|
+
self.pos = 0
|
|
130
|
+
self.line = 1
|
|
131
|
+
self.col = 1
|
|
132
|
+
self.tokens: list[Token] = []
|
|
133
|
+
self.indent_stack: list[int] = [0]
|
|
134
|
+
self.paren_depth = 0 # Track () and [] nesting to suppress NEWLINE/INDENT/DEDENT
|
|
135
|
+
|
|
136
|
+
def _peek(self, offset: int = 0) -> str:
|
|
137
|
+
idx = self.pos + offset
|
|
138
|
+
return self.source[idx] if idx < len(self.source) else "\0"
|
|
139
|
+
|
|
140
|
+
def _advance(self) -> str:
|
|
141
|
+
ch = self.source[self.pos]
|
|
142
|
+
self.pos += 1
|
|
143
|
+
if ch == "\n":
|
|
144
|
+
self.line += 1
|
|
145
|
+
self.col = 1
|
|
146
|
+
else:
|
|
147
|
+
self.col += 1
|
|
148
|
+
return ch
|
|
149
|
+
|
|
150
|
+
def _at_end(self) -> bool:
|
|
151
|
+
return self.pos >= len(self.source)
|
|
152
|
+
|
|
153
|
+
def _emit(self, tt: TokenType, value: str, line: int, col: int) -> None:
|
|
154
|
+
self.tokens.append(Token(tt, value, line, col))
|
|
155
|
+
|
|
156
|
+
def _skip_line(self) -> None:
|
|
157
|
+
while not self._at_end() and self.source[self.pos] != "\n":
|
|
158
|
+
self._advance()
|
|
159
|
+
if not self._at_end():
|
|
160
|
+
self._advance()
|
|
161
|
+
|
|
162
|
+
def _skip_comment(self) -> None:
|
|
163
|
+
while not self._at_end() and self.source[self.pos] != "\n":
|
|
164
|
+
self._advance()
|
|
165
|
+
|
|
166
|
+
def tokenize(self) -> list[Token]:
|
|
167
|
+
while not self._at_end():
|
|
168
|
+
self._tokenize_line()
|
|
169
|
+
|
|
170
|
+
while len(self.indent_stack) > 1:
|
|
171
|
+
self.indent_stack.pop()
|
|
172
|
+
self._emit(TokenType.DEDENT, "", self.line, self.col)
|
|
173
|
+
|
|
174
|
+
self._emit(TokenType.EOF_TOKEN, "", self.line, self.col)
|
|
175
|
+
return self.tokens
|
|
176
|
+
|
|
177
|
+
def _tokenize_line(self) -> None:
|
|
178
|
+
if self._at_end():
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
line_start = self.pos
|
|
182
|
+
|
|
183
|
+
# Peek at line content to detect blank/comment lines
|
|
184
|
+
temp = self.pos
|
|
185
|
+
while temp < len(self.source) and self.source[temp] in (" ", "\t"):
|
|
186
|
+
temp += 1
|
|
187
|
+
if temp >= len(self.source) or self.source[temp] == "\n":
|
|
188
|
+
self._advance_to(min(temp + 1, len(self.source)))
|
|
189
|
+
return
|
|
190
|
+
if self.source[temp: temp + 3] == "//@":
|
|
191
|
+
self._skip_line()
|
|
192
|
+
return
|
|
193
|
+
if self.source[temp: temp + 2] == "//":
|
|
194
|
+
self._skip_line()
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
# Inside parens/brackets: skip indentation handling, treat as continuation
|
|
198
|
+
if self.paren_depth > 0:
|
|
199
|
+
while not self._at_end() and self.source[self.pos] in (" ", "\t"):
|
|
200
|
+
self._advance()
|
|
201
|
+
emitted_in_parens = False
|
|
202
|
+
while not self._at_end() and self.source[self.pos] != "\n":
|
|
203
|
+
self._skip_whitespace_inline()
|
|
204
|
+
if self._at_end() or self.source[self.pos] == "\n":
|
|
205
|
+
break
|
|
206
|
+
if self.source[self.pos: self.pos + 2] == "//":
|
|
207
|
+
self._skip_comment()
|
|
208
|
+
break
|
|
209
|
+
emitted_in_parens = True
|
|
210
|
+
self._read_token()
|
|
211
|
+
if not self._at_end() and self.source[self.pos] == "\n":
|
|
212
|
+
self._advance()
|
|
213
|
+
# If parens closed on this line, emit NEWLINE so parser sees end of statement
|
|
214
|
+
if self.paren_depth == 0 and emitted_in_parens:
|
|
215
|
+
self._emit(TokenType.NEWLINE, "\\n", self.line - 1, self.col)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# Indentation
|
|
219
|
+
indent_level = 0
|
|
220
|
+
while not self._at_end() and self.source[self.pos] in (" ", "\t"):
|
|
221
|
+
ch = self._advance()
|
|
222
|
+
indent_level += 1
|
|
223
|
+
|
|
224
|
+
raw = self.source[line_start: self.pos]
|
|
225
|
+
if "\t" in raw:
|
|
226
|
+
indent_level = raw.count("\t")
|
|
227
|
+
else:
|
|
228
|
+
indent_level = len(raw) // 4
|
|
229
|
+
|
|
230
|
+
current_indent = self.indent_stack[-1]
|
|
231
|
+
if indent_level > current_indent:
|
|
232
|
+
self.indent_stack.append(indent_level)
|
|
233
|
+
self._emit(TokenType.INDENT, "", self.line, 1)
|
|
234
|
+
elif indent_level < current_indent:
|
|
235
|
+
while len(self.indent_stack) > 1 and self.indent_stack[-1] > indent_level:
|
|
236
|
+
self.indent_stack.pop()
|
|
237
|
+
self._emit(TokenType.DEDENT, "", self.line, 1)
|
|
238
|
+
|
|
239
|
+
# Tokens on this line
|
|
240
|
+
emitted_something = False
|
|
241
|
+
while not self._at_end() and self.source[self.pos] != "\n":
|
|
242
|
+
self._skip_whitespace_inline()
|
|
243
|
+
if self._at_end() or self.source[self.pos] == "\n":
|
|
244
|
+
break
|
|
245
|
+
if self.source[self.pos: self.pos + 2] == "//":
|
|
246
|
+
self._skip_comment()
|
|
247
|
+
break
|
|
248
|
+
emitted_something = True
|
|
249
|
+
self._read_token()
|
|
250
|
+
|
|
251
|
+
if not self._at_end() and self.source[self.pos] == "\n":
|
|
252
|
+
self._advance()
|
|
253
|
+
|
|
254
|
+
if emitted_something and self.paren_depth == 0:
|
|
255
|
+
self._emit(TokenType.NEWLINE, "\\n", self.line - 1, self.col)
|
|
256
|
+
|
|
257
|
+
def _advance_to(self, target: int) -> None:
|
|
258
|
+
while self.pos < target and self.pos < len(self.source):
|
|
259
|
+
self._advance()
|
|
260
|
+
|
|
261
|
+
def _skip_whitespace_inline(self) -> None:
|
|
262
|
+
while not self._at_end() and self.source[self.pos] in (" ", "\t"):
|
|
263
|
+
self._advance()
|
|
264
|
+
|
|
265
|
+
def _read_token(self) -> None:
|
|
266
|
+
ch = self.source[self.pos]
|
|
267
|
+
start_line = self.line
|
|
268
|
+
start_col = self.col
|
|
269
|
+
|
|
270
|
+
# Numbers
|
|
271
|
+
if ch.isdigit():
|
|
272
|
+
self._read_number(start_line, start_col)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
# Strings
|
|
276
|
+
if ch == '"':
|
|
277
|
+
self._read_string(start_line, start_col)
|
|
278
|
+
return
|
|
279
|
+
if ch == "'":
|
|
280
|
+
self._read_string_single(start_line, start_col)
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# Color literals (#rrggbb or #rrggbbaa)
|
|
284
|
+
if ch == "#":
|
|
285
|
+
self._advance() # consume #
|
|
286
|
+
buf = []
|
|
287
|
+
while not self._at_end() and (self.source[self.pos] in "0123456789abcdefABCDEF"):
|
|
288
|
+
buf.append(self._advance())
|
|
289
|
+
self._emit(TokenType.COLOR, "#" + "".join(buf), start_line, start_col)
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
# Identifiers / keywords
|
|
293
|
+
if ch.isalpha() or ch == "_":
|
|
294
|
+
self._read_ident(start_line, start_col)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
# Two-character operators (check first)
|
|
298
|
+
two = self.source[self.pos: self.pos + 2] if self.pos + 1 < len(self.source) else ""
|
|
299
|
+
if two == ":=":
|
|
300
|
+
self._advance(); self._advance()
|
|
301
|
+
self._emit(TokenType.COLON_EQUALS, ":=", start_line, start_col)
|
|
302
|
+
return
|
|
303
|
+
if two == "==":
|
|
304
|
+
self._advance(); self._advance()
|
|
305
|
+
self._emit(TokenType.EQEQ, "==", start_line, start_col)
|
|
306
|
+
return
|
|
307
|
+
if two == "!=":
|
|
308
|
+
self._advance(); self._advance()
|
|
309
|
+
self._emit(TokenType.NOTEQ, "!=", start_line, start_col)
|
|
310
|
+
return
|
|
311
|
+
if two == ">=":
|
|
312
|
+
self._advance(); self._advance()
|
|
313
|
+
self._emit(TokenType.GE, ">=", start_line, start_col)
|
|
314
|
+
return
|
|
315
|
+
if two == "<=":
|
|
316
|
+
self._advance(); self._advance()
|
|
317
|
+
self._emit(TokenType.LE, "<=", start_line, start_col)
|
|
318
|
+
return
|
|
319
|
+
if two == "=>":
|
|
320
|
+
self._advance(); self._advance()
|
|
321
|
+
self._emit(TokenType.FAT_ARROW, "=>", start_line, start_col)
|
|
322
|
+
return
|
|
323
|
+
if two == "+=":
|
|
324
|
+
self._advance(); self._advance()
|
|
325
|
+
self._emit(TokenType.PLUS_EQUALS, "+=", start_line, start_col)
|
|
326
|
+
return
|
|
327
|
+
if two == "-=":
|
|
328
|
+
self._advance(); self._advance()
|
|
329
|
+
self._emit(TokenType.MINUS_EQUALS, "-=", start_line, start_col)
|
|
330
|
+
return
|
|
331
|
+
if two == "*=":
|
|
332
|
+
self._advance(); self._advance()
|
|
333
|
+
self._emit(TokenType.STAR_EQUALS, "*=", start_line, start_col)
|
|
334
|
+
return
|
|
335
|
+
if two == "/=":
|
|
336
|
+
self._advance(); self._advance()
|
|
337
|
+
self._emit(TokenType.SLASH_EQUALS, "/=", start_line, start_col)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
# Single-character operators
|
|
341
|
+
singles = {
|
|
342
|
+
"(": TokenType.LPAREN,
|
|
343
|
+
")": TokenType.RPAREN,
|
|
344
|
+
"[": TokenType.LBRACKET,
|
|
345
|
+
"]": TokenType.RBRACKET,
|
|
346
|
+
",": TokenType.COMMA,
|
|
347
|
+
".": TokenType.DOT,
|
|
348
|
+
"=": TokenType.EQUALS,
|
|
349
|
+
"+": TokenType.PLUS,
|
|
350
|
+
"-": TokenType.MINUS,
|
|
351
|
+
"*": TokenType.STAR,
|
|
352
|
+
"/": TokenType.SLASH,
|
|
353
|
+
"%": TokenType.PERCENT,
|
|
354
|
+
">": TokenType.GT,
|
|
355
|
+
"<": TokenType.LT,
|
|
356
|
+
"?": TokenType.QUESTION,
|
|
357
|
+
":": TokenType.COLON,
|
|
358
|
+
}
|
|
359
|
+
if ch in singles:
|
|
360
|
+
self._advance()
|
|
361
|
+
tt = singles[ch]
|
|
362
|
+
if tt in (TokenType.LPAREN, TokenType.LBRACKET):
|
|
363
|
+
self.paren_depth += 1
|
|
364
|
+
elif tt in (TokenType.RPAREN, TokenType.RBRACKET):
|
|
365
|
+
self.paren_depth = max(0, self.paren_depth - 1)
|
|
366
|
+
self._emit(tt, ch, start_line, start_col)
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
# Unknown — skip
|
|
370
|
+
self._advance()
|
|
371
|
+
|
|
372
|
+
def _read_number(self, start_line: int, start_col: int) -> None:
|
|
373
|
+
buf = []
|
|
374
|
+
while not self._at_end() and (self.source[self.pos].isdigit() or self.source[self.pos] == "."):
|
|
375
|
+
buf.append(self._advance())
|
|
376
|
+
self._emit(TokenType.NUMBER, "".join(buf), start_line, start_col)
|
|
377
|
+
|
|
378
|
+
def _read_string(self, start_line: int, start_col: int) -> None:
|
|
379
|
+
self._advance() # consume "
|
|
380
|
+
buf = []
|
|
381
|
+
while not self._at_end() and self.source[self.pos] != '"':
|
|
382
|
+
if self.source[self.pos] == "\\" and self.pos + 1 < len(self.source):
|
|
383
|
+
self._advance() # skip backslash
|
|
384
|
+
buf.append(self._advance())
|
|
385
|
+
if not self._at_end():
|
|
386
|
+
self._advance() # consume closing "
|
|
387
|
+
self._emit(TokenType.STRING, "".join(buf), start_line, start_col)
|
|
388
|
+
|
|
389
|
+
def _read_string_single(self, start_line: int, start_col: int) -> None:
|
|
390
|
+
self._advance() # consume '
|
|
391
|
+
buf = []
|
|
392
|
+
while not self._at_end() and self.source[self.pos] != "'":
|
|
393
|
+
if self.source[self.pos] == "\\" and self.pos + 1 < len(self.source):
|
|
394
|
+
self._advance()
|
|
395
|
+
buf.append(self._advance())
|
|
396
|
+
if not self._at_end():
|
|
397
|
+
self._advance()
|
|
398
|
+
self._emit(TokenType.STRING, "".join(buf), start_line, start_col)
|
|
399
|
+
|
|
400
|
+
def _read_ident(self, start_line: int, start_col: int) -> None:
|
|
401
|
+
buf = []
|
|
402
|
+
while not self._at_end() and (self.source[self.pos].isalnum() or self.source[self.pos] == "_"):
|
|
403
|
+
buf.append(self._advance())
|
|
404
|
+
word = "".join(buf)
|
|
405
|
+
tt = KEYWORDS.get(word, TokenType.IDENT)
|
|
406
|
+
self._emit(tt, word, start_line, start_col)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""TradingView-aligned choice sets for `input.*` (compile-time metadata).
|
|
2
|
+
|
|
3
|
+
PineForge does not render the TradingView Settings UI; these frozensets document
|
|
4
|
+
the same **identifier** choices TV offers in common dropdowns so the analyzer
|
|
5
|
+
can validate **const** `defval` / `options` expressions with warnings (not hard
|
|
6
|
+
errors), matching TV’s intent without a runtime UI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
# Built-in OHLCV and common derived series (typical "Source" dropdown entries).
|
|
14
|
+
INPUT_SOURCE_SERIES_IDS: frozenset[str] = frozenset({
|
|
15
|
+
"open",
|
|
16
|
+
"high",
|
|
17
|
+
"low",
|
|
18
|
+
"close",
|
|
19
|
+
"volume",
|
|
20
|
+
"hl2",
|
|
21
|
+
"hlc3",
|
|
22
|
+
"ohlc4",
|
|
23
|
+
"hlcc4",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
# Common Pine timeframe strings (chart + typical dropdown values).
|
|
27
|
+
# See: https://www.tradingview.com/pine-script-reference/v6/#fun_timeframe
|
|
28
|
+
_INPUT_TF_NUMERIC = frozenset(
|
|
29
|
+
str(m) for m in (
|
|
30
|
+
1, 2, 3, 4, 5, 10, 15, 30, 45, 60, 90, 120, 180, 240, 360, 720,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
_INPUT_TF_SEC = frozenset(f"{s}S" for s in (1, 5, 15, 30, 45))
|
|
34
|
+
_INPUT_TF_DAY = frozenset(("1D", "2D", "3D", "D"))
|
|
35
|
+
_INPUT_TF_WEEK = frozenset(("1W", "2W", "3W", "W"))
|
|
36
|
+
_INPUT_TF_MONTH = frozenset(("1M", "2M", "3M", "6M", "12M", "M"))
|
|
37
|
+
|
|
38
|
+
INPUT_TIMEFRAME_CHOICES: frozenset[str] = (
|
|
39
|
+
frozenset({""})
|
|
40
|
+
| _INPUT_TF_NUMERIC
|
|
41
|
+
| _INPUT_TF_SEC
|
|
42
|
+
| _INPUT_TF_DAY
|
|
43
|
+
| _INPUT_TF_WEEK
|
|
44
|
+
| _INPUT_TF_MONTH
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Loose pattern for valid Pine timeframe literals beyond the canonical set above
|
|
48
|
+
# (e.g. custom minute counts).
|
|
49
|
+
_TIMEFRAME_RE = re.compile(
|
|
50
|
+
r"^(?:"
|
|
51
|
+
r"\s*|" # chart
|
|
52
|
+
r"[0-9]+S?|" # minutes or N-second bars
|
|
53
|
+
r"[0-9]+[DWM]|" # e.g. 2D, 3W
|
|
54
|
+
r"[DWM]" # bare D / W / M
|
|
55
|
+
r")$"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_valid_timeframe_string(s: str) -> bool:
|
|
60
|
+
"""Return True if *s* is a plausible Pine timeframe literal."""
|
|
61
|
+
if s in INPUT_TIMEFRAME_CHOICES:
|
|
62
|
+
return True
|
|
63
|
+
return bool(_TIMEFRAME_RE.match(s))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Typical session presets (informational; many custom strings are valid).
|
|
67
|
+
INPUT_SESSION_PRESETS: frozenset[str] = frozenset({
|
|
68
|
+
"",
|
|
69
|
+
"24x7",
|
|
70
|
+
"0000-2359",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
# HHMM-HHMM Mon-Fri style (same idea as TV session strings).
|
|
74
|
+
_SESSION_RANGE_RE = re.compile(r"^\d{4}-\d{4}$")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_plausible_session_string(s: str) -> bool:
|
|
78
|
+
"""Return True if *s* looks like a valid `input.session` defval."""
|
|
79
|
+
if s in INPUT_SESSION_PRESETS:
|
|
80
|
+
return True
|
|
81
|
+
if _SESSION_RANGE_RE.match(s):
|
|
82
|
+
return True
|
|
83
|
+
# e.g. "0930-1600:23456" with weekday flags
|
|
84
|
+
if re.match(r"^\d{4}-\d{4}(:[0-9]{7})?$", s):
|
|
85
|
+
return True
|
|
86
|
+
return False
|