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
pine2pyne/lexer.py ADDED
@@ -0,0 +1,630 @@
1
+ """
2
+ Lexer (tokenizer) for Pine Script v6.
3
+
4
+ Handles indentation-significant syntax, multi-line continuations,
5
+ and all Pine Script v6 token types.
6
+ """
7
+ import re
8
+ from typing import List, Optional
9
+ from .errors import LexerError
10
+ from .tokens import Token, TokenType, KEYWORDS, TYPE_KEYWORDS
11
+
12
+
13
+ class Lexer:
14
+ """Tokenizes Pine Script v6 source code."""
15
+
16
+ def __init__(self, source: str):
17
+ self.source = source
18
+ self.pos = 0
19
+ self.line = 1
20
+ self.column = 1
21
+ self.tokens: List[Token] = []
22
+ self.indent_stack: List[int] = [0] # Track indentation levels
23
+ self.paren_depth = 0 # Track depth inside (), [], {} for indentation-free zones
24
+ self.pine_version = 6 # from //@version: a v5 string's line break reads as one space
25
+
26
+ def current_char(self) -> Optional[str]:
27
+ """Get current character without advancing."""
28
+ if self.pos >= len(self.source):
29
+ return None
30
+ return self.source[self.pos]
31
+
32
+ def peek_char(self, offset: int = 1) -> Optional[str]:
33
+ """Look ahead at character at pos + offset."""
34
+ pos = self.pos + offset
35
+ if pos >= len(self.source):
36
+ return None
37
+ return self.source[pos]
38
+
39
+ def advance(self) -> Optional[str]:
40
+ """Consume and return current character, updating position."""
41
+ if self.pos >= len(self.source):
42
+ return None
43
+ char = self.source[self.pos]
44
+ self.pos += 1
45
+ if char == '\n':
46
+ self.line += 1
47
+ self.column = 1
48
+ else:
49
+ self.column += 1
50
+ return char
51
+
52
+ def skip_whitespace(self, skip_newlines: bool = False) -> None:
53
+ """Skip spaces and tabs (and optionally newlines)."""
54
+ while self.current_char() in (' ', '\t') or (skip_newlines and self.current_char() == '\n'):
55
+ self.advance()
56
+
57
+ def read_line_comment(self) -> Token:
58
+ """Read // style comment until end of line."""
59
+ start_line = self.line
60
+ start_col = self.column
61
+
62
+ # Check for compiler annotations. pos is on the first '/' of '//', so the
63
+ # '@' of '//@version' is at offset 2 — peek_char()'s default offset of 1
64
+ # only ever sees the second '/'.
65
+ if self.peek_char(2) == '@':
66
+ return self.read_annotation()
67
+ # `// @version=5`: the version still counts with spaces after `//` (PyneComp reads it)
68
+ offset = 2
69
+ while self.peek_char(offset) in (' ', '\t'):
70
+ offset += 1
71
+ if offset > 2 and self.source[self.pos + offset:self.pos + offset + 8] == '@version':
72
+ while offset > 2:
73
+ self.advance()
74
+ offset -= 1
75
+ return self.read_annotation()
76
+
77
+ # Regular comment
78
+ comment = ''
79
+ while self.current_char() and self.current_char() != '\n':
80
+ comment += self.advance()
81
+
82
+ return Token(TokenType.COMMENT, comment.strip(), start_line, start_col, comment)
83
+
84
+ def read_annotation(self) -> Token:
85
+ """Read //@annotation style compiler directive."""
86
+ start_line = self.line
87
+ start_col = self.column
88
+
89
+ # Skip //
90
+ self.advance()
91
+ self.advance()
92
+ # Skip @
93
+ self.advance()
94
+
95
+ # Read annotation name
96
+ annotation = ''
97
+ while self.current_char() and self.current_char() not in (' ', '\t', '\n', '='):
98
+ annotation += self.advance()
99
+
100
+ # Check for version annotation
101
+ if annotation == 'version':
102
+ self.skip_whitespace()
103
+ if self.current_char() == '=':
104
+ self.advance()
105
+ self.skip_whitespace()
106
+ version = ''
107
+ while self.current_char() and self.current_char() != '\n':
108
+ version += self.advance()
109
+ if version.strip().isdigit():
110
+ self.pine_version = int(version.strip())
111
+ return Token(TokenType.VERSION_ANNOTATION, version.strip(), start_line, start_col)
112
+
113
+ # Read rest of annotation
114
+ value = ''
115
+ while self.current_char() and self.current_char() != '\n':
116
+ value += self.advance()
117
+
118
+ return Token(TokenType.COMPILER_ANNOTATION, f"{annotation}{value}".strip(), start_line, start_col)
119
+
120
+ def read_string(self, quote: str) -> Token:
121
+ """Read string literal (single or double quoted)."""
122
+ start_line = self.line
123
+ start_col = self.column
124
+
125
+ # Skip opening quote
126
+ self.advance()
127
+
128
+ string = ''
129
+ while self.current_char() and self.current_char() != quote:
130
+ char = self.current_char()
131
+ if char == '\\':
132
+ # Handle escape sequences — unescape to actual characters
133
+ self.advance()
134
+ next_char = self.current_char()
135
+ if next_char == 'n':
136
+ string += '\n'
137
+ self.advance()
138
+ elif next_char == 't':
139
+ string += '\t'
140
+ self.advance()
141
+ elif next_char == 'r':
142
+ string += '\r'
143
+ self.advance()
144
+ elif next_char in ('"', "'", '\\'):
145
+ string += next_char
146
+ self.advance()
147
+ elif next_char is not None and next_char != '\n':
148
+ string += self.advance() # any other escaped character is itself: `\S` is `S` (PyneComp)
149
+ else:
150
+ string += char
151
+ elif char == '\n':
152
+ # a string may go on on the next line; the line break is not part of it, and outside
153
+ # parentheses PyneComp does not count it: the lines after the string are numbered one
154
+ # less (PineCoders/Time v5; a tooltip inside a call keeps the count)
155
+ self.advance()
156
+ if self.paren_depth == 0:
157
+ self.line -= 1
158
+ if self.pine_version < 6:
159
+ # Pine v5: the line break and the next line's indentation are one space (PineCoders/lower_tf,
160
+ # TradingView/LibraryCOT); v6 keeps the indentation (PineCoders/Time v5)
161
+ while self.current_char() in (' ', '\t'):
162
+ self.advance()
163
+ string += ' '
164
+ else:
165
+ string += self.advance()
166
+
167
+ if not self.current_char():
168
+ raise LexerError(f"Unterminated string literal", start_line, start_col)
169
+
170
+ # Skip closing quote
171
+ self.advance()
172
+
173
+ # Mark if double quoted (for preserving quote style in output)
174
+ is_double_quoted = (quote == '"')
175
+ return Token(TokenType.STRING_LITERAL, string, start_line, start_col,
176
+ f"{quote}{string}{quote}", is_double_quoted=is_double_quoted)
177
+
178
+ def read_number(self) -> Token:
179
+ """Read integer or float literal."""
180
+ start_line = self.line
181
+ start_col = self.column
182
+
183
+ number = ''
184
+ has_dot = False
185
+ has_e = False
186
+
187
+ while self.current_char():
188
+ char = self.current_char()
189
+
190
+ if char.isdigit():
191
+ number += self.advance()
192
+ elif char == '.' and not has_dot and not has_e:
193
+ next_ch = self.peek_char()
194
+ if next_ch and next_ch.isdigit():
195
+ # 100.5 — normal decimal
196
+ has_dot = True
197
+ number += self.advance()
198
+ elif next_ch is None or not (next_ch.isalpha() or next_ch == '_' or next_ch == '.'):
199
+ # 100. — trailing dot float (not member access like 100.toString)
200
+ has_dot = True
201
+ number += self.advance()
202
+ else:
203
+ break
204
+ elif char in ('e', 'E') and not has_e:
205
+ has_e = True
206
+ number += self.advance()
207
+ # Handle optional +/- after e
208
+ if self.current_char() in ('+', '-'):
209
+ number += self.advance()
210
+ # An exponent marker with no digits after it (1e, 1e+) would reach
211
+ # float() below and leak a raw ValueError past the lexer's error
212
+ # boundary. Reject it here with position info instead.
213
+ if not (self.current_char() or '').isdigit():
214
+ raise LexerError(
215
+ f"Malformed exponent in numeric literal: {number!r}",
216
+ start_line, start_col,
217
+ )
218
+ else:
219
+ break
220
+
221
+ if has_dot or has_e:
222
+ return Token(TokenType.FLOAT_LITERAL, float(number), start_line, start_col, number)
223
+ else:
224
+ return Token(TokenType.INT_LITERAL, int(number), start_line, start_col, number)
225
+
226
+ def read_color_literal(self) -> Token:
227
+ """Read #RRGGBB or #RRGGBBAA color literal."""
228
+ start_line = self.line
229
+ start_col = self.column
230
+
231
+ color = self.advance() # #
232
+
233
+ # Read hex digits
234
+ while self.current_char() and self.current_char() in '0123456789abcdefABCDEF':
235
+ color += self.advance()
236
+
237
+ # Validate length (6 or 8 hex digits after #)
238
+ if len(color) not in (7, 9):
239
+ raise LexerError(f"Invalid color literal: {color}", start_line, start_col)
240
+
241
+ return Token(TokenType.COLOR_LITERAL, color, start_line, start_col, color)
242
+
243
+ def read_identifier(self) -> Token:
244
+ """Read identifier or keyword."""
245
+ start_line = self.line
246
+ start_col = self.column
247
+
248
+ identifier = ''
249
+ while self.current_char() and (self.current_char().isalnum() or self.current_char() in ('_', '.')):
250
+ identifier += self.advance()
251
+
252
+ # `type` is a keyword only at the head of a user-defined-type declaration (card #115)
253
+ if identifier == 'type' and not self._heads_type_declaration():
254
+ return Token(TokenType.IDENTIFIER, identifier, start_line, start_col, identifier)
255
+
256
+ # `method` is a keyword only where it heads a method declaration (`method name(`);
257
+ # elsewhere it is a name (`f(string method) =>`, `method == "SMA"`)
258
+ if identifier == 'method' and not self._heads_method_declaration():
259
+ return Token(TokenType.IDENTIFIER, identifier, start_line, start_col, identifier)
260
+
261
+ # Check if it's a keyword
262
+ if identifier in KEYWORDS:
263
+ token_type = KEYWORDS[identifier]
264
+ return Token(token_type, identifier, start_line, start_col, identifier)
265
+
266
+ # Check if it's a type keyword
267
+ if identifier in TYPE_KEYWORDS:
268
+ return Token(TokenType.TYPE_IDENTIFIER, identifier, start_line, start_col, identifier)
269
+
270
+ return Token(TokenType.IDENTIFIER, identifier, start_line, start_col, identifier)
271
+
272
+ def _heads_method_declaration(self) -> bool:
273
+ """After the word `method`: a name and then `(` on the same line."""
274
+ offset = 0
275
+ while self.peek_char(offset) in (' ', '\t'):
276
+ offset += 1
277
+ start = offset
278
+ while (self.peek_char(offset) or ' ').isalnum() or self.peek_char(offset) == '_':
279
+ offset += 1
280
+ if offset == start:
281
+ return False
282
+ while self.peek_char(offset) in (' ', '\t'):
283
+ offset += 1
284
+ return self.peek_char(offset) == '('
285
+
286
+ def _heads_type_declaration(self) -> bool:
287
+ """True for `type Name`: first on its statement (or after `export`), a name following on the same line.
288
+
289
+ Anywhere else `type` is an ordinary name (the Pine v2-v4 `type=` argument of input(), a variable,
290
+ a parameter, a field). The parser accepted a TYPE token only at the head of a declaration, so a
291
+ source that parsed before is not changed by this.
292
+ """
293
+ previous = self.tokens[-1].type if self.tokens else None
294
+ if previous not in (None, TokenType.NEWLINE, TokenType.INDENT, TokenType.DEDENT, TokenType.EXPORT):
295
+ return False
296
+ offset = 0
297
+ while self.peek_char(offset) in (' ', '\t'):
298
+ offset += 1
299
+ following = self.peek_char(offset)
300
+ if following is None or not (following.isalpha() or following == '_'):
301
+ return False
302
+ name_end = offset
303
+ while (self.peek_char(name_end) or ' ').isalnum() or self.peek_char(name_end) == '_':
304
+ name_end += 1
305
+ following_word = self.source[self.pos + offset:self.pos + name_end]
306
+ return following_word not in ('and', 'or', 'in', 'to', 'by')
307
+
308
+ def _is_continuation_line(self) -> bool:
309
+ """Check if this line is a continuation of the previous expression.
310
+
311
+ Pine Script treats indented lines as continuations when:
312
+ 1. The line starts with a binary operator keyword (or/and)
313
+ 2. The previous line ended with an operator that needs a right operand (?, :, and, or, +, -, etc.)
314
+ """
315
+ # Check if line starts with a continuation keyword
316
+ if self.current_char() and self.current_char().isalpha():
317
+ word = ''
318
+ temp_pos = self.pos
319
+ while temp_pos < len(self.source) and self.source[temp_pos].isalpha():
320
+ word += self.source[temp_pos]
321
+ temp_pos += 1
322
+ if word in ('or', 'and'):
323
+ return True
324
+ # A line that starts with the `?` or the `:` of a ternary, or with a comma, continues the one above
325
+ if self.current_char() in ('?', ':', ',') and self.peek_char() != '=':
326
+ return True
327
+
328
+ # Check if previous line ended with an operator that needs a right operand
329
+ # These operators always indicate the expression continues on the next line
330
+ CONTINUATION_END_TOKENS = {
331
+ TokenType.TERNARY, # ?
332
+ TokenType.COLON, # : (ternary false branch)
333
+ TokenType.AND, # and
334
+ TokenType.OR, # or
335
+ TokenType.PLUS, # +
336
+ TokenType.MINUS, # -
337
+ TokenType.MULT, # *
338
+ TokenType.DIV, # /
339
+ TokenType.MOD, # %
340
+ TokenType.COMMA, # ,
341
+ # Assignment/comparison operators can never end a complete expression,
342
+ # so a line ending with one always continues onto the next indented
343
+ # line, e.g. `x =` \n ` "a" + "b"` (line-wrapped RHS after `=`).
344
+ TokenType.ASSIGN, # =
345
+ TokenType.REASSIGN, # :=
346
+ TokenType.PLUS_ASSIGN, # +=
347
+ TokenType.MINUS_ASSIGN, # -=
348
+ TokenType.MULT_ASSIGN, # *=
349
+ TokenType.DIV_ASSIGN, # /=
350
+ TokenType.MOD_ASSIGN, # %=
351
+ TokenType.GT, # >
352
+ TokenType.LT, # <
353
+ TokenType.GTE, # >=
354
+ TokenType.LTE, # <=
355
+ TokenType.EQ, # ==
356
+ TokenType.NEQ, # !=
357
+ }
358
+ if self.tokens:
359
+ last_token = self.tokens[-1]
360
+ if last_token.type in CONTINUATION_END_TOKENS:
361
+ return True
362
+
363
+ return False
364
+
365
+ def handle_indentation(self, indent_level: int) -> List[Token]:
366
+ """
367
+ Generate INDENT/DEDENT tokens based on indentation change.
368
+
369
+ Uses lenient matching: if exact level not in stack, finds closest level.
370
+ This handles real-world Pine Script files with varied indentation styles.
371
+ """
372
+ tokens = []
373
+ current_level = self.indent_stack[-1]
374
+
375
+ if indent_level > current_level:
376
+ # Increase indentation
377
+ self.indent_stack.append(indent_level)
378
+ tokens.append(Token(TokenType.INDENT, None, self.line, self.column))
379
+ elif indent_level < current_level:
380
+ # Decrease indentation (may generate multiple DEDENT tokens)
381
+ while self.indent_stack and self.indent_stack[-1] > indent_level:
382
+ self.indent_stack.pop()
383
+ tokens.append(Token(TokenType.DEDENT, None, self.line, self.column))
384
+
385
+ # Lenient matching: if exact level not found, find closest match
386
+ if not self.indent_stack:
387
+ # Should never happen, but reset to base
388
+ self.indent_stack.append(0)
389
+ elif self.indent_stack[-1] != indent_level:
390
+ # Find closest level at or below target
391
+ # This handles cases where continuation lines use different indentation
392
+ closest_level = max(level for level in self.indent_stack if level <= indent_level)
393
+
394
+ # If we found a closer match, pop until we reach it
395
+ while self.indent_stack and self.indent_stack[-1] > closest_level:
396
+ self.indent_stack.pop()
397
+ tokens.append(Token(TokenType.DEDENT, None, self.line, self.column))
398
+
399
+ # If we need to add the new level
400
+ if closest_level < indent_level:
401
+ self.indent_stack.append(indent_level)
402
+ tokens.append(Token(TokenType.INDENT, None, self.line, self.column))
403
+
404
+ return tokens
405
+
406
+ def tokenize(self) -> List[Token]:
407
+ """Tokenize the entire source code."""
408
+ self.tokens = []
409
+ at_line_start = True
410
+
411
+ while self.pos < len(self.source):
412
+ char = self.current_char()
413
+
414
+ # Handle newlines and indentation
415
+ if char == '\n':
416
+ # Skip empty lines and lines with only comments
417
+ saved_pos = self.pos
418
+ saved_line = self.line
419
+ saved_col = self.column
420
+
421
+ self.advance() # Skip \n
422
+
423
+ # Count indentation on next line
424
+ indent_level = 0
425
+ while self.current_char() in (' ', '\t'):
426
+ if self.current_char() == ' ':
427
+ indent_level += 1
428
+ else: # tab
429
+ indent_level += 4
430
+ self.advance()
431
+
432
+ # Check if line is empty or comment-only
433
+ if self.current_char() in ('\n', None) or (self.current_char() == '/' and self.peek_char() == '/'):
434
+ # Empty line or comment line - skip indentation tracking
435
+ if self.current_char() is None:
436
+ break
437
+ continue
438
+
439
+ # Check for line continuation: increased indent + starts with binary operator
440
+ # In Pine Script, indented lines starting with or/and continue the previous expression
441
+ # Pine also wraps at the block's own indentation when that is not a multiple of 4
442
+ # (`f(x) =>` / ` a ? b :` / ` c`)
443
+ wraps_in_place = (indent_level == self.indent_stack[-1] and indent_level % 4 != 0
444
+ and self.tokens and self.tokens[-1].type in (
445
+ TokenType.TERNARY, TokenType.COLON, TokenType.AND, TokenType.OR,
446
+ TokenType.PLUS, TokenType.MINUS, TokenType.MULT, TokenType.DIV))
447
+ # a statement cannot end with a comma: `var a = 1,` / `var b = 2` is one line
448
+ wraps_in_place = wraps_in_place or (indent_level == self.indent_stack[-1]
449
+ and self.tokens and self.tokens[-1].type == TokenType.COMMA)
450
+ if (self.paren_depth == 0
451
+ and (indent_level > self.indent_stack[-1] or wraps_in_place)
452
+ and self._is_continuation_line()):
453
+ # Suppress NEWLINE and INDENT — treat as seamless continuation
454
+ at_line_start = False
455
+ continue
456
+
457
+ # Inside parentheses/brackets/braces: suppress NEWLINE and indentation
458
+ # Multi-line expressions inside delimiters are seamless
459
+ if self.paren_depth > 0:
460
+ at_line_start = False
461
+ continue
462
+
463
+ # Emit NEWLINE token for previous line
464
+ self.tokens.append(Token(TokenType.NEWLINE, '\n', saved_line, saved_col))
465
+
466
+ # Handle indentation changes
467
+ if self.paren_depth == 0:
468
+ indent_tokens = self.handle_indentation(indent_level)
469
+ self.tokens.extend(indent_tokens)
470
+
471
+ at_line_start = False
472
+ continue
473
+
474
+ # Skip inline whitespace
475
+ if char in (' ', '\t'):
476
+ self.skip_whitespace()
477
+ continue
478
+
479
+ # Comments and annotations
480
+ if char == '/' and self.peek_char() == '/':
481
+ token = self.read_line_comment()
482
+ # Don't add comments to token stream (or add them with a flag)
483
+ # For now, skip comments except annotations
484
+ if token.type in (TokenType.VERSION_ANNOTATION, TokenType.COMPILER_ANNOTATION):
485
+ self.tokens.append(token)
486
+ continue
487
+
488
+ # String literals
489
+ if char in ('"', "'"):
490
+ self.tokens.append(self.read_string(char))
491
+ continue
492
+
493
+ # Color literals
494
+ if char == '#':
495
+ self.tokens.append(self.read_color_literal())
496
+ continue
497
+
498
+ # Numbers
499
+ if char.isdigit():
500
+ self.tokens.append(self.read_number())
501
+ continue
502
+
503
+ # Identifiers and keywords
504
+ if char.isalpha() or char == '_':
505
+ self.tokens.append(self.read_identifier())
506
+ continue
507
+
508
+ # Operators and delimiters
509
+ start_line = self.line
510
+ start_col = self.column
511
+
512
+ # Two-character operators
513
+ if char == ':' and self.peek_char() == '=':
514
+ self.advance()
515
+ self.advance()
516
+ self.tokens.append(Token(TokenType.REASSIGN, ':=', start_line, start_col, ':='))
517
+ continue
518
+
519
+ # Compound assignment operators
520
+ if char == '+' and self.peek_char() == '=':
521
+ self.advance()
522
+ self.advance()
523
+ self.tokens.append(Token(TokenType.PLUS_ASSIGN, '+=', start_line, start_col, '+='))
524
+ continue
525
+
526
+ if char == '-' and self.peek_char() == '=':
527
+ self.advance()
528
+ self.advance()
529
+ self.tokens.append(Token(TokenType.MINUS_ASSIGN, '-=', start_line, start_col, '-='))
530
+ continue
531
+
532
+ if char == '*' and self.peek_char() == '=':
533
+ self.advance()
534
+ self.advance()
535
+ self.tokens.append(Token(TokenType.MULT_ASSIGN, '*=', start_line, start_col, '*='))
536
+ continue
537
+
538
+ if char == '/' and self.peek_char() == '=':
539
+ self.advance()
540
+ self.advance()
541
+ self.tokens.append(Token(TokenType.DIV_ASSIGN, '/=', start_line, start_col, '/='))
542
+ continue
543
+
544
+ if char == '%' and self.peek_char() == '=':
545
+ self.advance()
546
+ self.advance()
547
+ self.tokens.append(Token(TokenType.MOD_ASSIGN, '%=', start_line, start_col, '%='))
548
+ continue
549
+
550
+ if char == '=' and self.peek_char() == '>':
551
+ self.advance()
552
+ self.advance()
553
+ self.tokens.append(Token(TokenType.ARROW, '=>', start_line, start_col, '=>'))
554
+ continue
555
+
556
+ if char == '=' and self.peek_char() == '=':
557
+ self.advance()
558
+ self.advance()
559
+ self.tokens.append(Token(TokenType.EQ, '==', start_line, start_col, '=='))
560
+ continue
561
+
562
+ if char == '!' and self.peek_char() == '=':
563
+ self.advance()
564
+ self.advance()
565
+ self.tokens.append(Token(TokenType.NEQ, '!=', start_line, start_col, '!='))
566
+ continue
567
+
568
+ if char == '>' and self.peek_char() == '=':
569
+ self.advance()
570
+ self.advance()
571
+ self.tokens.append(Token(TokenType.GTE, '>=', start_line, start_col, '>='))
572
+ continue
573
+
574
+ if char == '<' and self.peek_char() == '=':
575
+ self.advance()
576
+ self.advance()
577
+ self.tokens.append(Token(TokenType.LTE, '<=', start_line, start_col, '<='))
578
+ continue
579
+
580
+ # Single-character operators and delimiters
581
+ single_char_tokens = {
582
+ '=': TokenType.ASSIGN,
583
+ '+': TokenType.PLUS,
584
+ '-': TokenType.MINUS,
585
+ '*': TokenType.MULT,
586
+ '/': TokenType.DIV,
587
+ '%': TokenType.MOD,
588
+ '>': TokenType.GT,
589
+ '<': TokenType.LT,
590
+ '?': TokenType.TERNARY,
591
+ ':': TokenType.COLON,
592
+ '.': TokenType.DOT,
593
+ '(': TokenType.LPAREN,
594
+ ')': TokenType.RPAREN,
595
+ '[': TokenType.LBRACKET,
596
+ ']': TokenType.RBRACKET,
597
+ ',': TokenType.COMMA,
598
+ }
599
+
600
+ if char in single_char_tokens:
601
+ token_type = single_char_tokens[char]
602
+
603
+ # Track delimiter depth for indentation-free zones
604
+ if char in ('(', '[', '{'):
605
+ self.paren_depth += 1
606
+ elif char in (')', ']', '}'):
607
+ self.paren_depth = max(0, self.paren_depth - 1)
608
+
609
+ self.advance()
610
+ self.tokens.append(Token(token_type, char, start_line, start_col, char))
611
+ continue
612
+
613
+ # Unknown character
614
+ raise LexerError(f"Unexpected character: {char!r}", self.line, self.column)
615
+
616
+ # Emit final DEDENT tokens to return to base indentation
617
+ while len(self.indent_stack) > 1:
618
+ self.indent_stack.pop()
619
+ self.tokens.append(Token(TokenType.DEDENT, None, self.line, self.column))
620
+
621
+ # Add EOF token
622
+ self.tokens.append(Token(TokenType.EOF, None, self.line, self.column))
623
+
624
+ return self.tokens
625
+
626
+
627
+ def tokenize(source: str) -> List[Token]:
628
+ """Convenience function to tokenize Pine Script source."""
629
+ lexer = Lexer(source)
630
+ return lexer.tokenize()