opencrbasic 1.0.0__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 (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
opencrbasic/parser.py ADDED
@@ -0,0 +1,3264 @@
1
+ """CRBasic parser: logical lines -> `syntax.Program`.
2
+
3
+ `parse_file(path)` / `parse_text(text, ...)` never raise for malformed input; a statement the
4
+ parser cannot make sense of becomes `syntax.Unparsed` with a reason, and parsing continues with
5
+ the next statement.
6
+
7
+ Conditional compilation (`#If`/`#ElseIf`/`#Else`/`#EndIf`, `#If_no_remove` as a synonym for
8
+ `#If`, `#UnDef`) is resolved here, against `consteval`, using `source.py`'s per-line `section`
9
+ binding for `Section` inside an `IncludeSection()`. A branch that is never taken contributes no
10
+ statements and defines no `Const` -- this is also how the corpus's
11
+ `ERROR__STATIC_ASSERT__...`-style "static assert" pattern works: the identifier alone is not
12
+ valid CRBasic syntax (confirmed with the oracle: bare `Foo` where `Foo` doesn't name a Sub gives
13
+ `Invalid, or out of place expression`), so it only "works" because the branch that would parse it
14
+ is always the one that's skipped. This parser does not reject an unresolved bare identifier itself
15
+ (that is a semantic check on a compiled Sub table, left to `checker.py`); it always treats it as a
16
+ zero-argument `Call`, which is more lenient than CR6Comp on inputs the oracle would reject anyway
17
+ and does not affect acceptance on inputs the oracle accepts. See `docs/frontend.md`.
18
+
19
+ Two-word terminators (`Next Scan`, `End If`, `End Select`, `End Sub`, `Exit Sub`, ...) fall out of
20
+ the *generic* instruction parser without special-casing: `NEXT`/`END`/`EXIT`/`SELECT`'s test
21
+ expression are all "keyword, then a bare (unparenthesized) comma list of expressions", and a bare
22
+ trailing word such as `Scan` parses as a plain `Name` like any other bare identifier -- so
23
+ `Instruction(keyword="NEXT", args=[Name("Scan")])` and `Instruction(keyword="NEXT",
24
+ args=[Name("I")])` (a real loop counter) share the same shape, and one-word forms (`NextScan`,
25
+ `EndIf`, ...) are simply different, separately-tabulated instruction names with the same generic
26
+ handling.
27
+ """
28
+
29
+ import collections
30
+ import dataclasses
31
+ import re
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+ from typing import cast
35
+
36
+ from . import consteval, lexer, profiles, source
37
+ from . import syntax as S
38
+ from .diagnostics import Diagnostic, Diagnostics
39
+ from .lexer import Token
40
+ from .sources import DiskSource, SourceProvider
41
+
42
+ _DIRECTIVE_RE = re.compile(r"^\s*#\s*(If_no_remove|If|ElseIf|Else|EndIf|UnDef)\b(.*)$", re.IGNORECASE)
43
+ _THEN_SUFFIX_RE = re.compile(r"\bThen\s*$", re.IGNORECASE)
44
+ _LEADING_WORD_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*\$?)")
45
+
46
+ _RELOPS = {"=", "<>", "<", ">", "<=", ">="}
47
+ _COMPOUND_OPS = {"+=", "-=", "*=", "/=", "\\=", "^=", "&="}
48
+
49
+ # Distinguishable `Unparsed.reason` markers for a genuinely unclosed parenthesis reached through
50
+ # the *general expression* grammar (an assignment's RHS, a nested call's own arguments -- anything
51
+ # parsed via `_parse_expr`) -- unlike a bare instruction/`Call` statement's own argument list,
52
+ # which stays lenient about running off the end of the line (`expect_close`, unchanged: oracle-
53
+ # confirmed `Call Foo(1,2,3` with nothing further compiles). `checker/structure.py` matches these
54
+ # two literal strings to report the real compiler's own message instead of the generic "Invalid,
55
+ # or out of place expression" fallback. See `docs/checker.md` "Need closing ) shapes".
56
+ UNCLOSED_PAREN_EXPR = "unclosed parenthesized expression"
57
+ UNCLOSED_DIM_PREFIX = "unclosed dim: "
58
+
59
+
60
+ # `SNMPVariable(Name,1.3.6.1.4.1.5,Float,RONLY)`: a dotted OID literal is not an expression; the real
61
+ # compiler accepts any digits-and-dots OID there (oracle 2026-10-12: `1.4.9`, `2.5.7.1`, `10.20.30.40`
62
+ # compile), so such a statement is `Unparsed` with this fixed reason.
63
+ SNMP_OID_REASON = "dotted SNMP OID literal"
64
+ _SNMP_OID_STATEMENT = re.compile(r"\s*SNMPVariable\s*\(\s*(?:[^,]+,\s*)?\d[\d.\s]*[,)]", re.I)
65
+
66
+
67
+ class _ParseError(Exception):
68
+ pass
69
+
70
+
71
+ CONST_NOMATCH = "\x00N"
72
+ CONST_INCOMPLETE = "\x00I"
73
+ _CONST_TYPE_WORDS = frozenset({"LONG", "FLOAT", "DOUBLE", "STRING", "BOOLEAN"})
74
+
75
+
76
+ class _TokStream:
77
+ __slots__ = ("toks", "i", "seg_text")
78
+
79
+ def __init__(self, toks: list[Token], seg_text: str = ""):
80
+ self.toks = toks
81
+ self.i = 0
82
+ # The physical line this token stream was lexed from, verbatim (empty for the two call
83
+ # sites that never need it: directive/`Alias`-lhs sub-token-streams). Column-sliced by
84
+ # `_parse_vardecl`'s own unclosed-dimension-list check (`Token.col` indexes straight into
85
+ # this string, oracle-confirmed -- see `docs/checker.md` "Need closing ) shapes").
86
+ self.seg_text = seg_text
87
+
88
+ def peek(self, k: int = 0) -> Token:
89
+ j = self.i + k
90
+ return self.toks[j] if j < len(self.toks) else self.toks[-1]
91
+
92
+ def next(self) -> Token:
93
+ t = self.peek()
94
+ if t.kind != "EOF":
95
+ self.i += 1
96
+ return t
97
+
98
+ def at_end(self) -> bool:
99
+ return self.peek().kind == "EOF"
100
+
101
+ def remaining(self) -> list[Token]:
102
+ return self.toks[self.i :]
103
+
104
+ def is_op(self, text: str, k: int = 0) -> bool:
105
+ t = self.peek(k)
106
+ return t.kind == "OP" and t.text == text
107
+
108
+ def is_ident(self, upper: str, k: int = 0) -> bool:
109
+ t = self.peek(k)
110
+ return t.kind == "IDENT" and t.upper == upper
111
+
112
+ def expect_op(self, text: str) -> Token:
113
+ if self.is_op(text):
114
+ return self.next()
115
+ t = self.peek()
116
+ raise _ParseError(f"expected {text!r}, found {t.text or t.kind!r}")
117
+
118
+ def expect_close(self, text: str) -> Token:
119
+ """Like `expect_op`, but a `)`/`}` implicitly closes at end-of-line: CR6Comp accepts an
120
+ unclosed paren that runs off the end of a logical line, silently completing the call
121
+ right there (confirmed with the oracle: `Call Foo(1,2,3` with nothing further on the
122
+ line compiles; only a genuinely missing argument -- a trailing comma, `Call Foo(1,2,` --
123
+ is rejected, and even then as a semantic `Call argument required.`, not a parse error).
124
+ See docs/frontend.md.
125
+ """
126
+ if self.at_end():
127
+ return self.peek()
128
+ return self.expect_op(text)
129
+
130
+ def expect_ident(self, upper: str | None = None) -> Token:
131
+ t = self.peek()
132
+ if t.kind == "IDENT" and (upper is None or t.upper == upper):
133
+ return self.next()
134
+ want = upper if upper else "an identifier"
135
+ raise _ParseError(f"expected {want}, found {t.text or t.kind!r}")
136
+
137
+
138
+ def _eof_like(line: int) -> Token:
139
+ return Token("EOF", "", "", 0, line)
140
+
141
+
142
+ def _split_text_segments(text: str) -> list[str]:
143
+ """Split a logical line's raw text on every `:` outside `"..."` strings (parentheses do not
144
+ protect a `:`, oracle 2026-10-16, all four loggers: `docs/checker.md` "Colon inside parentheses"),
145
+ without tokenising -- so a segment whose content isn't tokenisable CRBasic at all (a `Units`
146
+ label) can still be found and routed separately. Mirrors `_split_colon_segments`.
147
+ """
148
+ segments: list[str] = []
149
+ current: list[str] = []
150
+ in_string = False
151
+ i = 0
152
+ n = len(text)
153
+ while i < n:
154
+ ch = text[i]
155
+ if in_string:
156
+ current.append(ch)
157
+ if ch == '"':
158
+ if i + 1 < n and text[i + 1] == '"':
159
+ current.append(text[i + 1])
160
+ i += 2
161
+ continue
162
+ in_string = False
163
+ i += 1
164
+ continue
165
+ if ch == '"':
166
+ in_string = True
167
+ current.append(ch)
168
+ i += 1
169
+ continue
170
+ if ch == ":":
171
+ segments.append("".join(current))
172
+ current = []
173
+ i += 1
174
+ continue
175
+ current.append(ch)
176
+ i += 1
177
+ segments.append("".join(current))
178
+ return segments
179
+
180
+
181
+ _RAW_NAME_FIRST = frozenset({"DATATABLE", "CONSTTABLE"})
182
+
183
+
184
+ _ASSIGN_OPS = frozenset({"=", "+=", "-=", "*=", "/=", "\\=", "^=", "&="})
185
+
186
+
187
+ def _is_assignment(toks: list[Token]) -> bool:
188
+ """`name`, optional `(...)` subscripts and `.member` parts, then an assignment operator."""
189
+ if not toks or toks[0].kind != "IDENT":
190
+ return False
191
+ i, depth = 1, 0
192
+ while i < len(toks):
193
+ t = toks[i]
194
+ if t.kind == "OP" and t.text == "(":
195
+ depth += 1
196
+ elif t.kind == "OP" and t.text == ")":
197
+ depth -= 1
198
+ elif depth == 0:
199
+ if t.kind == "OP" and t.text in _ASSIGN_OPS:
200
+ return True
201
+ if not (t.kind == "OP" and t.text == "." or t.kind == "IDENT" and toks[i - 1].text == "."):
202
+ return False
203
+ i += 1
204
+ return False
205
+
206
+
207
+ def _drop_semicolons(tokens: list[Token], profile) -> list[Token]:
208
+ """CR6/CR1000X skip a `;` like a blank inside an assignment (also leading, `; X = 1`), and
209
+ after the trailing end of a `Const`, `For`, `Next`, `While` or `Select Case` header or of the
210
+ assignment body of a one-line `If`: `X = 1 ;`, `X =; 1` and `X = 1 ; + 2` compile, while
211
+ `X = 1 ; Y = 2` and `X = 1 ;)` are rejected as if the `;` were absent. CR300/CR350 accept a
212
+ trailing `;` only after `Next` (`Next Y ;`). Oracle, 2026-10-12, docs/checker.md "Round 13". An
213
+ instruction call (`Delay(1,1,Sec) ;`), `Do ;`, `CallTable T ;`, `EndIf ;` and `If c Then ;` keep
214
+ their own messages."""
215
+ real = [t for t in tokens if t.kind != "EOF"]
216
+ eof = tokens[len(real) :]
217
+ semi = lambda t: t.kind == "OP" and t.text == ";" # noqa: E731
218
+ body = [t for t in real if not semi(t)]
219
+ if len(body) == len(real) or not body or body[0].kind != "IDENT":
220
+ return tokens
221
+ head = body[0].upper
222
+ trailing_only = real[: len(body)] == body # every `;` follows the last other token
223
+ first = real[0] is body[0]
224
+
225
+ def glued(word: str) -> bool: # `Then;`, `Next;`: the `;` makes the keyword part of a longer word
226
+ return any(
227
+ semi(t) and a.kind == "IDENT" and a.upper == word and t.col == a.col + len(a.text)
228
+ for a, t in zip(real, real[1:])
229
+ )
230
+
231
+ if head == "NEXT":
232
+ ok = first and not glued("NEXT")
233
+ elif head == "ENDSELECT":
234
+ if first and semi(real[1]) and not glued("ENDSELECT"):
235
+ j = 1 # only the first gap-free group of `;` is skipped (`EndSelect ; ;` is `Too many parameters: ;.`)
236
+ while j + 1 < len(real) and semi(real[j + 1]) and real[j + 1].col == real[j].col + 1:
237
+ j += 1
238
+ return [real[0], *real[j + 1 :], *eof]
239
+ ok = False
240
+ elif head == "CASE" and [t.upper for t in body[1:]] == ["ELSE"] and first and real[:2] == body:
241
+ ok = True # `Case Else ;` (all four loggers)
242
+ elif (
243
+ head == "ELSEIF"
244
+ and body[-1].upper == "THEN"
245
+ and real[: len(body)] == body
246
+ and first
247
+ and len(body) > 2
248
+ and not glued("THEN")
249
+ ):
250
+ ok = True # `ElseIf c Then ;` (all four loggers)
251
+ elif head in ("SUB", "FUNCTION"):
252
+ ok = trailing_only and first and len(body) > 1 # `Sub S ;`, `Function F(a) ;` (all four loggers)
253
+ elif profile.name not in ("cr6", "cr1000x"):
254
+ ok = False
255
+ elif head in ("IF", "ELSEIF"):
256
+ then = max((i for i, t in enumerate(body) if t.kind == "IDENT" and t.upper == "THEN"), default=-1)
257
+ if then < 0 or not first or glued("THEN") or glued("ELSE"):
258
+ ok = False # also `; If c Then`
259
+ elif then + 1 < len(body):
260
+ ok = _is_assignment(body[then + 1 :])
261
+ else:
262
+ ok = head == "ELSEIF" or not any(semi(t) for t in real[real.index(body[then]) + 1 :])
263
+ elif head == "CONST":
264
+ eq = next((i for i, t in enumerate(real) if t.kind == "OP" and t.text == "="), len(real))
265
+ ok = first and not any(semi(t) for t in real[:eq]) # `Const ; K = 1`, `Const K ; = 1` are rejected
266
+ elif head in ("FOR", "WHILE", "SELECT"):
267
+ ok = first
268
+ elif head in ("DO", "LOOP", "CASE"):
269
+ # (`Do ; While c` and `Loop ; Until c` are rejected: only the condition skips a `;`)
270
+ ok = (
271
+ first
272
+ and len(body) > 1
273
+ and not (head == "CASE" and [t.upper for t in body[1:]] == ["ELSE"])
274
+ and (head == "CASE" or real[1] is body[1])
275
+ )
276
+ elif (
277
+ head in profile.functions
278
+ and head not in profile.instructions
279
+ and trailing_only
280
+ and first
281
+ and len(body) > 1
282
+ and body[1].text == "("
283
+ and body[-1].text == ")"
284
+ ):
285
+ ok = True # `SerialOpen(ComRS232,9600,0,0,50) ;` (oracle, CR6, 2026-10-12)
286
+ else:
287
+ member = len(body) > 1 and body[1].kind == "OP" and body[1].text == "." # `Public.X = 1 ;`
288
+ ok = (head not in profile.instructions or member and head in ("PUBLIC", "DIM")) and _is_assignment(body)
289
+ if ok:
290
+ return body + eof
291
+ if profile.name in ("cr6", "cr1000x") and head not in ("SUB", "FUNCTION"):
292
+ # (the table name slot keeps its `;`: `DataTable (T ;,1,-1)`, see `_parse_segment`)
293
+ name_slot = (
294
+ 3 if head == "DATATABLE" and len(real) > 3 and real[1].text == "(" and real[2].kind == "IDENT" else -1
295
+ )
296
+ depth, kept = 0, []
297
+ for i, t in enumerate(real):
298
+ if t.kind == "OP" and t.text in "()":
299
+ depth += 1 if t.text == "(" else -1
300
+ if not (semi(t) and depth > 0 and i != name_slot):
301
+ kept.append(t)
302
+ if len(kept) < len(real):
303
+ return kept + eof
304
+ return tokens
305
+
306
+
307
+ _S1_CALLS = frozenset({"ABS", "ABSLONG", "ARRAYLENGTH", "TYPEOF"})
308
+ _S2_CALLS = frozenset({"LINENUM", "SIGNATURE"})
309
+ _SELF_CLOSING_CALLS = _S1_CALLS | _S2_CALLS
310
+ _BIN_OPS = frozenset({"+", "-", "*", "/", "^", "&", "=", "<>", "<", ">", "<=", ">=", "\\"})
311
+ _BIN_WORDS = frozenset({"AND", "OR", "XOR", "MOD", "EQV", "IMP"})
312
+
313
+
314
+ def _match_paren(toks: list[Token], i: int) -> int | None:
315
+ depth = 0
316
+ for j in range(i, len(toks)):
317
+ if toks[j].kind == "OP" and toks[j].text == "(":
318
+ depth += 1
319
+ elif toks[j].kind == "OP" and toks[j].text == ")":
320
+ depth -= 1
321
+ if depth == 0:
322
+ return j
323
+ return None
324
+
325
+
326
+ def _is_binary(t: Token) -> bool:
327
+ return (t.kind == "OP" and t.text in _BIN_OPS) or (t.kind == "IDENT" and t.upper in _BIN_WORDS)
328
+
329
+
330
+ def _expr_end(toks: list[Token], i: int = 0) -> int | None:
331
+ """Index just past the simple expression (operands, binary operators, closed groups, member
332
+ chains) starting at `toks[i]`, or None when there is none."""
333
+ n = len(toks)
334
+ while True:
335
+ while i < n and (
336
+ toks[i].kind == "OP" and toks[i].text in "+-" or toks[i].kind == "IDENT" and toks[i].upper == "NOT"
337
+ ):
338
+ i += 1
339
+ if i >= n:
340
+ return None
341
+ t = toks[i]
342
+ if t.kind in ("NUMBER", "STRING"):
343
+ i += 1
344
+ elif t.kind == "IDENT":
345
+ i += 1
346
+ while i < n:
347
+ if toks[i].kind == "OP" and toks[i].text == "." and i + 1 < n and toks[i + 1].kind == "IDENT":
348
+ i += 2
349
+ elif toks[i].kind == "OP" and toks[i].text == "(":
350
+ j = _match_paren(toks, i)
351
+ if j is None:
352
+ return None
353
+ i = j + 1
354
+ else:
355
+ break
356
+ elif t.kind == "OP" and t.text == "(":
357
+ j = _match_paren(toks, i)
358
+ if j is None:
359
+ return None
360
+ i = j + 1
361
+ else:
362
+ return None
363
+ if i < n and _is_binary(toks[i]):
364
+ i += 1
365
+ continue
366
+ return i
367
+
368
+
369
+ def _close_open_flat(tokens: list[Token], profile) -> list[Token]:
370
+ """`X = Abs(X`, `If Abs(X + 1 Then`, `Do While AbsLong(X`: these six built-ins accept their
371
+ one argument without the closing parenthesis, which the end of the statement (or the `Then` of
372
+ an `If`/`ElseIf`) supplies, when it is the only open group and holds a single argument
373
+ (oracle, all four loggers, 2026-10-12, `tests/fixtures/r14_abs_unclosed.json`). The comma,
374
+ juxtaposed-operand, nested-call and empty-argument shapes behave differently per function and
375
+ stay as they were."""
376
+ real = [t for t in tokens if t.kind != "EOF"]
377
+ stack: list[int] = []
378
+ end = len(real)
379
+ head = real[0].upper if real and real[0].kind == "IDENT" else ""
380
+ for i, t in enumerate(real):
381
+ if t.kind == "OP" and t.text == "(":
382
+ stack.append(i)
383
+ elif t.kind == "OP" and t.text == ")":
384
+ if not stack:
385
+ return tokens
386
+ stack.pop()
387
+ elif t.kind == "IDENT" and t.upper == "THEN" and stack and head in ("IF", "ELSEIF"):
388
+ end = i
389
+ break
390
+ if len(stack) != 1 or head in ("WHILE", "LOOP") and end != len(real):
391
+ return tokens
392
+ open_at = stack[0]
393
+ prev = real[open_at - 1] if open_at else None
394
+ if (
395
+ prev is None
396
+ or prev.kind != "IDENT"
397
+ or prev.upper not in _SELF_CLOSING_CALLS
398
+ or open_at + 1 >= end
399
+ or (open_at > 1 and real[open_at - 2].text == ".")
400
+ ):
401
+ return tokens
402
+ if any(t.kind == "OP" and t.text in (",", "{", "}") for t in real[open_at + 1 : end]) or any(
403
+ t.kind == "IDENT" and t.upper == "THEN" for t in real[open_at + 1 : end]
404
+ ):
405
+ return tokens
406
+ close = lexer.Token("OP", ")", ")", real[end - 1].col + len(real[end - 1].text), real[end - 1].line)
407
+ return real[:end] + [close] + real[end:] + tokens[len(real) :]
408
+
409
+
410
+ class _Closed(list):
411
+ """Tokens of a statement whose unclosed group was closed; `dropped` holds the second,
412
+ side-by-side expression of `Abs(X Q` that the closing left out (see `cr3xx.check_dropped_tail`)."""
413
+
414
+ dropped: list = []
415
+
416
+
417
+ def _close_open_call(tokens: list[Token], profile) -> list[Token]:
418
+ """Unclosed groups the real compilers accept (oracle, all four loggers, 2026-10-12,
419
+ `tests/fixtures/r14_abs_unclosed.json`, `r15_abs_unclosed.json`; `docs/checker.md` "Round 15").
420
+ The `Then` and everything after it in a `While`/`Do While|Until`/`Loop While|Until` condition
421
+ is ignored; then the flat `Abs(X`, and the nested / trailing-comma / juxtaposed / empty and
422
+ `LineNum(X)`-in-an-open-group shapes of `_close_open_extra`."""
423
+ real = [t for t in tokens if t.kind != "EOF"]
424
+ original = tokens
425
+ if (
426
+ real
427
+ and real[0].kind == "IDENT"
428
+ and (
429
+ real[0].upper == "WHILE"
430
+ or real[0].upper in ("DO", "LOOP")
431
+ and len(real) > 1
432
+ and real[1].kind == "IDENT"
433
+ and real[1].upper in ("WHILE", "UNTIL")
434
+ )
435
+ ):
436
+ cut = next((i for i, t in enumerate(real) if t.kind == "IDENT" and t.upper == "THEN"), None)
437
+ if cut is not None:
438
+ tokens = real[:cut] + tokens[len(real) :]
439
+ out = _close_open_extra(tokens, profile)
440
+ if out is tokens:
441
+ out = _close_open_flat(tokens, profile)
442
+ if out is tokens and tokens is not original:
443
+ depth = sum(1 if t.text == "(" else -1 if t.text == ")" else 0 for t in tokens if t.kind == "OP")
444
+ if depth > 0: # still unclosed after the cut (`While (C Then`): keep the old rejection
445
+ return original
446
+ return out
447
+
448
+
449
+ def _close_open_extra(tokens: list[Token], profile) -> list[Token]:
450
+ real = [t for t in tokens if t.kind != "EOF"]
451
+ eof = tokens[len(real) :]
452
+ head = real[0].upper if real and real[0].kind == "IDENT" else ""
453
+ stack: list[int] = []
454
+ end = len(real)
455
+ for i, t in enumerate(real):
456
+ if t.kind == "OP" and t.text == "(":
457
+ stack.append(i)
458
+ elif t.kind == "OP" and t.text == ")":
459
+ if not stack:
460
+ return tokens
461
+ stack.pop()
462
+ elif t.kind == "IDENT" and t.upper == "THEN" and stack and head in ("IF", "ELSEIF"):
463
+ end = i
464
+ break
465
+ if not stack or any(t.kind == "OP" and t.text in "{}" for t in real[:end]):
466
+ return tokens
467
+ last = real[end - 1]
468
+
469
+ def mk(kind: str, text: str, value=None) -> Token:
470
+ return lexer.Token(kind, text, text, last.col + len(last.text), last.line, value)
471
+
472
+ def call_name(o: int) -> str:
473
+ return (
474
+ real[o - 1].upper if o and real[o - 1].kind == "IDENT" and not (o > 1 and real[o - 2].text == ".") else ""
475
+ )
476
+
477
+ names = [call_name(o) for o in stack]
478
+ n = len(stack)
479
+ inner = stack[-1]
480
+ content = real[inner + 1 : end]
481
+ if head in ("IF", "ELSEIF") and any(t.kind == "IDENT" and t.upper == "THEN" for t in real[:end]):
482
+ return tokens
483
+
484
+ def closed(body: list[Token]) -> list[Token]:
485
+ return real[: inner + 1] + body + [mk("OP", ")")] * n + real[end:] + eof
486
+
487
+ # ---- calls of the self-closing family, possibly nested in one another ----
488
+ if all(nm in _S1_CALLS for nm in names[:-1]) and names[-1] in _SELF_CLOSING_CALLS:
489
+ for k in range(1, n): # what stands between an outer `(` and the next call: nothing or `... op`
490
+ prefix = real[stack[k - 1] + 1 : stack[k] - 1]
491
+ if any(t.kind == "OP" and t.text == "," for t in prefix) or (prefix and not _is_binary(prefix[-1])):
492
+ return tokens
493
+ if names[-1] in _S1_CALLS and content:
494
+ # `n` open groups take up to `n + 1` side-by-side expressions, or `n` of them before a
495
+ # trailing comma (oracle, all four loggers, 2026-10-12: `Abs(X Z` | `Abs(Abs(X Z Z` |
496
+ # `Abs(Abs(Abs(X Z Z Z` compile, one more does not; `Abs(X,` | `Abs(Abs(X Z,` compile,
497
+ # `Abs(X Z,` | `Abs(Abs(X Z Z,` do not)
498
+ exprs: list[list[Token]] = []
499
+ tail = content
500
+ while tail and tail[0].text != ",":
501
+ e = _expr_end(tail, 0)
502
+ if not e:
503
+ return tokens
504
+ exprs.append(tail[:e])
505
+ tail = tail[e:]
506
+ comma = len(tail) == 1 and tail[0].text == ","
507
+ if (tail and not comma) or len(exprs) > (n if comma else n + 1) or not exprs:
508
+ return tokens
509
+ if len(exprs) == 1 and not comma:
510
+ return closed(content) if n > 1 else tokens
511
+ dropped = [t for part in exprs[1:] for t in part]
512
+ if any(part[0].kind == "IDENT" and len(part) > 1 and part[1].text == "(" for part in exprs[1:]):
513
+ return tokens # `Abs(X Q(1)`: Incomplete expression (oracle, 2026-10-12)
514
+ out = _Closed(closed(exprs[0]))
515
+ out.dropped = dropped
516
+ return out
517
+ if (
518
+ names[-1] in _S2_CALLS
519
+ and (not content or n == 1 and [t.text for t in content] == [","])
520
+ and not any(t.kind == "IDENT" and t.upper in _S2_CALLS for t in real[: inner - 1])
521
+ ):
522
+ return closed([mk("NUMBER", "0", 0)])
523
+ if names[-1] in _S2_CALLS and n > 1 and content:
524
+ e1 = _expr_end(content, 0)
525
+ if e1 == len(content) and not any(t.text == "," for t in content):
526
+ return closed(content)
527
+ return tokens
528
+ # ---- `Y = A(LineNum(X) + 1`: a closed LineNum/Signature call inside one other open group ----
529
+ simple_assign = len(real) > 2 and real[0].kind == "IDENT" and real[1].kind == "OP" and real[1].text == "="
530
+ if (
531
+ n == 1
532
+ and simple_assign
533
+ and head not in profile.instructions
534
+ and end == len(real)
535
+ and names[0] not in _SELF_CLOSING_CALLS
536
+ ):
537
+ if any(t.text == "," for t in content) or any(
538
+ t.kind == "IDENT" and t.upper in _S2_CALLS for t in real[: inner - 1]
539
+ ):
540
+ return tokens
541
+ calls = [i for i, t in enumerate(content) if t.kind == "IDENT" and t.upper in _S2_CALLS]
542
+ if len(calls) != 1 or calls[0] + 1 >= len(content) or content[calls[0] + 1].text != "(":
543
+ return tokens
544
+ c = calls[0]
545
+ close = _match_paren(content, c + 1)
546
+ pre, post = content[:c], content[close + 1 :] if close is not None else None
547
+ if close is None:
548
+ return tokens
549
+ if pre and not _is_binary(pre[-1]):
550
+ return tokens
551
+ if post and not (_is_binary(post[0]) and _expr_end(post, 1) == len(post)):
552
+ return tokens
553
+ return real[:end] + [mk("OP", ")")] + real[end:] + eof
554
+ # ---- statement `A(LineNum(X) = 1`: the call closes the index too ----
555
+ if (
556
+ n == 1
557
+ and stack[0] == 1
558
+ and len(real) > 5
559
+ and real[0].kind == "IDENT"
560
+ and real[2].kind == "IDENT"
561
+ and real[2].upper in _S2_CALLS
562
+ and real[3].text == "("
563
+ and head not in profile.instructions
564
+ ):
565
+ close = _match_paren(real, 3)
566
+ if (
567
+ close is not None
568
+ and close + 1 < len(real)
569
+ and real[close + 1].kind == "OP"
570
+ and real[close + 1].text == "="
571
+ and close + 2 < len(real)
572
+ ):
573
+ one = lexer.Token("NUMBER", "1", "1", real[2].col, real[2].line, 1)
574
+ return real[:2] + [one, lexer.Token("OP", ")", ")", real[2].col, real[2].line)] + real[close + 1 :] + eof
575
+ return tokens
576
+
577
+
578
+ _INLINE_BLOCK_WORDS = frozenset({"SELECT", "WHILE", "NEXT", "ENDSELECT", "WEND", "SUBSCAN", "NEXTSUBSCAN", "NEXTSCAN"})
579
+
580
+
581
+ def _is_block_statement(st: S.Stmt) -> bool:
582
+ if isinstance(st, (S.DoLoop, S.For)):
583
+ return True
584
+ if not isinstance(st, S.Instruction):
585
+ return False
586
+ word = st.keyword.upper()
587
+ return (
588
+ word in _INLINE_BLOCK_WORDS
589
+ or word == "END"
590
+ and bool(st.args)
591
+ and isinstance(st.args[0], S.Name)
592
+ and st.args[0].name.upper() == "SELECT"
593
+ )
594
+
595
+
596
+ def _hoist_block_statements(stmt: S.If) -> list[S.Stmt]:
597
+ """`If c Then Do`, `If c Then Y = 1 Else For ...`, `If c Then Next`: a block keyword in the body
598
+ of a one-line `If` opens or closes its block like a statement of its own (oracle, all four
599
+ loggers, 2026-10-12: the `Loop`/`Next`/`EndSelect` that follows is matched, a missing one is
600
+ `DO without LOOP.`). The statements move out of the body, in order, behind the `If`."""
601
+ hoisted: list[S.Stmt] = []
602
+ for attr in ("inline", "inline_else"):
603
+ body = getattr(stmt, attr)
604
+ if body:
605
+ for position, st in enumerate(body):
606
+ if _is_block_statement(st):
607
+ st.hoisted_last = position == len(body) - 1 # (the reachability warning)
608
+ hoisted.append(st)
609
+ setattr(stmt, attr, [st for st in body if not _is_block_statement(st)])
610
+ for st in hoisted:
611
+ if hasattr(stmt, "origin"):
612
+ st.origin = stmt.origin
613
+ return hoisted
614
+
615
+
616
+ def _split_colon_segments(tokens: list[Token]) -> list[list[Token]]:
617
+ """Split a fully-tokenised logical line on every `:`."""
618
+ segments: list[list[Token]] = []
619
+ current: list[Token] = []
620
+ eof = tokens[-1]
621
+ for t in tokens[:-1]:
622
+ if t.kind == "OP" and t.text == ":":
623
+ segments.append(current)
624
+ current = []
625
+ continue
626
+ current.append(t)
627
+ segments.append(current)
628
+ return [seg + [eof] for seg in segments]
629
+
630
+
631
+ @dataclass
632
+ class _CondFrame:
633
+ any_true: bool
634
+ current: bool
635
+ parent_ok: bool
636
+
637
+
638
+ _WORD_BINARY_OPS = {"MOD", "AND", "OR", "XOR", "EQV", "IMP"} # `Fn(1) MOD 2` is one expression
639
+ _FUNCTION_DEF_RE = re.compile(r"\s*Function\s+([A-Za-z_][A-Za-z0-9_]*)", re.I)
640
+
641
+
642
+ _LOGICAL_LEAD_OPS = {"+", "-", "*", "/", "\\", "^", "&", "<", ">", "<=", ">=", "<>", "=<", "=>", "><"}
643
+
644
+ LOGICAL_ASSIGN = "logical-prefixed assignment"
645
+
646
+
647
+ def _has_assign(expr) -> bool:
648
+ return isinstance(expr, S.Binary) and (expr.op == "ASSIGN" or _has_assign(expr.left) or _has_assign(expr.right))
649
+
650
+
651
+ _DIGIT_NAME = re.compile(r"[\d.][\w.]*")
652
+
653
+ _DEFERRED_SOURCE_INSTRUCTIONS = frozenset(
654
+ {"SAMPLE", "AVERAGE", "MAXIMUM", "MINIMUM", "TOTALIZE", "MEDIAN", "STDDEV", "MOMENT", "COVARIANCE"}
655
+ )
656
+
657
+
658
+ def _dims_after_type_scan(text: str, open_col: int, close_col: int) -> int | None:
659
+ """How many dimensions survive the reference's search for `As`: it tokenizes on space/tab
660
+ from just after the *first* `)` of the dimension list, writing a terminator over the space that
661
+ ends its first token, and the dimensions are then read up to that terminator (`FUN_004baf00`,
662
+ oracle, CR6/CR1000X, round 58, docs/checker.md#parenthesised-dimensions-and-spaces-round-58).
663
+ `Dim A(2,(3), 4)` keeps 2 dimensions. None when nothing is cut, or the cut falls inside a
664
+ dimension (not modelled)."""
665
+ first_close = text.find(")", open_col + 1, close_col + 1)
666
+ delims, depth = [], 0
667
+ for i in range(open_col + 1, close_col):
668
+ c = text[i]
669
+ depth += c in "({"
670
+ depth -= c in ")}"
671
+ if c == "," and depth == 0:
672
+ delims.append(i)
673
+ delims.append(close_col)
674
+ if not delims[0] < first_close < close_col:
675
+ return None
676
+ i = first_close + 1
677
+ while i < len(text) and text[i] in " \t":
678
+ i += 1
679
+ while i < len(text) and text[i] not in " \t":
680
+ i += 1
681
+ if i >= close_col:
682
+ return None
683
+ kept = sum(d < i for d in delims)
684
+ start = delims[kept - 1] + 1 if kept else open_col + 1
685
+ return kept if not text[start:i].strip() else None
686
+
687
+
688
+ def _raw_word(p: _TokStream, delims: str) -> str:
689
+ """The source text from the next token up to the first of `delims` (or a tab)."""
690
+ col = p.peek().col
691
+ end = col + 1
692
+ while end < len(p.seg_text) and p.seg_text[end] not in delims + "\t":
693
+ end += 1
694
+ return p.seg_text[col:end].rstrip()
695
+
696
+
697
+ class _Parser:
698
+ def __init__(self, profile: profiles.Profile, diags: Diagnostics, provenance: bool = True):
699
+ self.provenance = provenance # False: no `Stmt.origin`, no `line_map` (language-server-only data)
700
+ self.profile = profile
701
+ self.diags = diags
702
+ self.env = consteval.Env(profile)
703
+ self.cond_stack: list[_CondFrame] = []
704
+ self.program = S.Program(name="")
705
+ self.line_map: dict[str, dict[int, list[source.LineSpan]]] = {} # reported line -> physical
706
+ self._done = False # set once a top-level EndProg is seen; see `run()`
707
+ self._inline_body = False # parsing the statements of a one-line `If`
708
+ self._in_struct = False # inside StructureType ... EndStructureType
709
+ self._in_table = False # inside DataTable ... EndTable
710
+ self.function_names: set[str] = set()
711
+ self._collect = False
712
+ self._to_ok = False # `To` as a binary operator: assignment values and `Select Case`
713
+ self._logical_assign = False # `A AND B = C`: a logical operand may be an assignment
714
+
715
+ # ------------------------------------------------------------ driver
716
+
717
+ def run(self, lines: list[source.LogicalLine], name: str) -> tuple[S.Program, Diagnostics]:
718
+ self.program.name = name
719
+ if not self._collect and any(_FUNCTION_DEF_RE.match(ll.text) for ll in lines):
720
+ # only definitions the compiler really reads count (not after EndProg, not in an
721
+ # inactive #If; oracle, 2026-10-10): a throwaway pass finds them
722
+ probe = _Parser(self.profile, Diagnostics(), self.provenance)
723
+ probe._collect = True
724
+ probe.run(lines, name)
725
+ self.function_names = probe.function_names
726
+ skipped_include_depth = 0
727
+ includes_stopped = False
728
+ todo = collections.deque(lines)
729
+ unshift: dict[str, int] = {} # per file: physical lines of unread (inactive) initializer pulls given back
730
+ while todo:
731
+ ll = todo.popleft()
732
+ if unshift.get(ll.file):
733
+ ll = dataclasses.replace(ll, line=ll.line + unshift[ll.file])
734
+ if ll.unpulled is not None and not skipped_include_depth and not self._active():
735
+ todo.extendleft(reversed(ll.unpulled))
736
+ unshift[ll.file] = unshift.get(ll.file, 0) + ll.pulls
737
+ continue
738
+ self._note_line(ll)
739
+ if skipped_include_depth:
740
+ if ll.kind == "include_begin":
741
+ skipped_include_depth += 1
742
+ elif ll.kind == "include_end":
743
+ skipped_include_depth -= 1
744
+ continue
745
+ if ll.kind == "include_begin":
746
+ if not self._active() or includes_stopped:
747
+ skipped_include_depth = 1
748
+ continue
749
+ if ll.kind == "include_end":
750
+ continue
751
+ if ll.kind in ("include_warning", "include_error"):
752
+ if self._active():
753
+ (self.diags.warning if ll.kind == "include_warning" else self.diags.error)(
754
+ ll.line, ll.text, ll.file
755
+ )
756
+ if ll.kind == "include_error": # (CR300/CR350 cascade trigger, `cr3xx`)
757
+ self.program.include_error_at.append(len(self.program.statements))
758
+ # CR6/CR1000X: after an `IncludeSection` extension error no later include is
759
+ # loaded; a plain `Include` error stops nothing (oracle, round 66,
760
+ # docs/checker.md#includes-after-an-includesection-error-round-66)
761
+ includes_stopped |= ll.stops_includes and not self.profile.single_pass
762
+ continue
763
+ if ll.kind == "overflow":
764
+ self.diags.error(ll.line, "Line exceeds max characters of 512.", ll.file)
765
+ break
766
+ m = _DIRECTIVE_RE.match(ll.text)
767
+ if m and m.group(1).upper() in ("ELSEIF", "ELSE", "ENDIF") and not self.cond_stack:
768
+ # a stray one is an unknown `#` statement: `Unknown instruction` ends the compile
769
+ # (oracle, all four loggers, round 40)
770
+ m = None
771
+ if m:
772
+ kind = m.group(1).upper()
773
+ if kind == "IF_NO_REMOVE":
774
+ kind = "IF"
775
+ self.env.section = ll.section
776
+ self._handle_directive(kind, m.group(2), ll.line, ll.file)
777
+ continue
778
+ if not self._active():
779
+ continue
780
+ self.env.section = ll.section
781
+ if self._collect and (m := _FUNCTION_DEF_RE.match(ll.text)):
782
+ self.function_names.add(m.group(1).upper())
783
+ if ll.bad_init:
784
+ self.diags.error(ll.line, ll.bad_init[0], ll.file)
785
+ self.diags.error(ll.line, f"Variable initialization of {ll.bad_init[1]} has bad syntax.", ll.file)
786
+ before = len(self.program.statements)
787
+ self._parse_line(ll)
788
+ if self.program.hash_cut is None and any(S.hash_statement(st) for st in self.program.statements[before:]):
789
+ self.program.hash_cut = len(self.diags.items)
790
+ if self._done:
791
+ # CR6Comp stops reading the file at the first top-level EndProg -- confirmed with
792
+ # the oracle: garbage (even unlexable text, even a second full program) after it
793
+ # compiles fine, because it is never looked at. See docs/frontend.md.
794
+ break
795
+ for ll in todo: # never reached (after EndProg or an overflow line): positions only
796
+ for l in ll.unpulled or [ll]:
797
+ self._note_line(
798
+ dataclasses.replace(l, line=l.line + unshift.get(ll.file, 0) + (ll.pulls if ll.unpulled else 0))
799
+ )
800
+ if ll.unpulled is not None:
801
+ unshift[ll.file] = unshift.get(ll.file, 0) + ll.pulls
802
+ if self.cond_stack: # read lines only: nothing after the first `EndProg` is looked at
803
+ # `#If True : #Foo : #EndIf` leaves the `#If` open: the rest of a directive line is not read,
804
+ # so its `#EndIf` is not seen (oracle, all four loggers, 2026-10-11)
805
+ self.program.unclosed_if = True
806
+ self.program.consts = dict(self.env.consts)
807
+ return self.program, self.diags
808
+
809
+ def _note_line(self, ll: source.LogicalLine) -> None:
810
+ """Record, for `positions.py`, the physical lines of `ll` under the line it is reported at."""
811
+ if ll.phys is None or self._collect or not self.provenance:
812
+ return
813
+ span = source.LineSpan(ll.phys[0], ll.phys[1], ll.occurrence)
814
+ spans = self.line_map.setdefault(ll.file, {}).setdefault(ll.line, [])
815
+ if span not in spans:
816
+ spans.append(span)
817
+
818
+ def _active(self) -> bool:
819
+ return all(f.current for f in self.cond_stack)
820
+
821
+ # -------------------------------------------------------- directives
822
+
823
+ def _handle_directive(self, kind: str, rest: str, line: int, file: str) -> None:
824
+ if kind == "IF":
825
+ parent_ok = self._active()
826
+ cond = self._eval_directive_cond(rest, line, file) if parent_ok else False
827
+ self.cond_stack.append(_CondFrame(any_true=cond, current=cond, parent_ok=parent_ok))
828
+ elif kind == "ELSEIF":
829
+ if not self.cond_stack:
830
+ self.diags.error(line, "#ElseIf without #If.", file)
831
+ return
832
+ frame = self.cond_stack[-1]
833
+ if not frame.parent_ok or frame.any_true:
834
+ frame.current = False
835
+ else:
836
+ cond = self._eval_directive_cond(rest, line, file)
837
+ frame.current = cond
838
+ frame.any_true = frame.any_true or cond
839
+ elif kind == "ELSE":
840
+ if not self.cond_stack:
841
+ self.diags.error(line, "#Else without #If.", file)
842
+ return
843
+ frame = self.cond_stack[-1]
844
+ frame.current = frame.parent_ok and not frame.any_true
845
+ frame.any_true = frame.any_true or frame.current
846
+ elif kind == "ENDIF":
847
+ if self.cond_stack:
848
+ self.cond_stack.pop()
849
+ else:
850
+ self.diags.error(line, "#EndIf without #If.", file)
851
+ elif kind == "UNDEF":
852
+ name = rest.strip().split()[0].upper() if rest.strip() else ""
853
+ self.env.consts.pop(name, None)
854
+
855
+ def _undefined_directive_names(self, expr: S.Expr) -> list[str]:
856
+ if isinstance(expr, S.Name):
857
+ try:
858
+ consteval.value_of(expr, self.env)
859
+ return []
860
+ except consteval.NotConstant:
861
+ return [expr.name]
862
+ parts = [getattr(expr, a, None) for a in ("left", "right", "operand")] + list(getattr(expr, "args", None) or [])
863
+ return [n for p in parts if isinstance(p, S.Expr) for n in self._undefined_directive_names(p)]
864
+
865
+ def _eval_directive_cond(self, rest: str, line: int, file: str) -> bool:
866
+ # malformed conditions: oracle, all four loggers, round 39 (docs/checker.md#if-conditions-round-39)
867
+ echo = rest.strip() # echoed up to `Then`, its spacing kept (round 41: `Nope < 1 .`)
868
+ m = re.search(r"(?i)\bThen\b", echo)
869
+ if m and echo[: m.start()].strip():
870
+ echo = echo[: m.start()]
871
+ text = echo.strip()
872
+ if not text:
873
+ self.diags.error(line, "Invalid expression in #If statement.", file)
874
+ return False
875
+ try:
876
+ tokens = lexer.tokenize(text, line)
877
+ p = _TokStream(tokens)
878
+ expr = self._parse_expr(p, line, file)
879
+ except lexer.LexError, _ParseError:
880
+ verb = (
881
+ "No Matching, or out of place ) in expression: "
882
+ if text.count("(") > text.count(")")
883
+ else "Invalid, or out of place expression: "
884
+ )
885
+ self.diags.error(line, f"{verb}{echo}.", file)
886
+ return False
887
+ if not (p.at_end() or p.is_op(":") or p.is_ident("THEN")): # (`#If True Then #Foo : #EndIf`)
888
+ self.diags.error(line, f"Incomplete expression: {echo}.", file)
889
+ return False
890
+ try:
891
+ value = consteval.value_of(expr, self.env)
892
+ return consteval.truthy(value)
893
+ except consteval.NotConstant:
894
+ if self.profile.single_pass: # each undefined name (`#If Nope < 1` -> `Nope is not defined.`)
895
+ for name in self._undefined_directive_names(expr):
896
+ self.diags.error(line, self.profile.unresolved_symbol_message.format(name=name), file)
897
+ else:
898
+ self.diags.error(line, f"Invalid symbol in expression: {echo}.", file)
899
+ return False
900
+
901
+ # ---------------------------------------------------------- one line
902
+
903
+ def _parse_line(self, ll: source.LogicalLine) -> None:
904
+ # `Units`' label is free-form text, not tokenisable CRBasic (`%`, a raw ISO-8859-1 `deg`
905
+ # byte, ...), so a top-level (outside `(...)`/strings) `:` split happens at the character
906
+ # level first, and only a segment that isn't a `Units` statement gets tokenised at all
907
+ # (found by corpus testing -- `Public TC(120) : Units TC = C` -- see docs/frontend.md).
908
+ segments = _split_text_segments(ll.text)
909
+ last_idx = len(segments) - 1
910
+ seg_offsets = [0]
911
+ for seg in segments:
912
+ seg_offsets.append(seg_offsets[-1] + len(seg) + 1)
913
+ in_inline_if = False
914
+ colon_if = False
915
+ for idx, seg_text_raw in enumerate(segments):
916
+ if not seg_text_raw.strip() or seg_text_raw.strip() == "=":
917
+ continue # (a segment holding only `=` compiles silently, oracle, all four loggers, 2026-10-10)
918
+ if colon_if and re.fullmatch(r"\s*(?:Else|EndIf|End\s+If)\s*", seg_text_raw, re.I):
919
+ continue # inside `If c Then : ...` these are part of the one-line If
920
+ # Only the segment that is both last on its physical line (nothing after it split off
921
+ # by a top-level `:`) and not followed by a stripped `'` comment truly reaches that
922
+ # line's real end -- see `Stmt.at_eol`'s docstring.
923
+ at_eol = idx == last_idx and not ll.had_trailing_comment and not in_inline_if
924
+ word = _LEADING_WORD_RE.match(seg_text_raw)
925
+ if word and word.group(1).upper() == "UNITS":
926
+ stmt = self._parse_units_segment(seg_text_raw, ll.line, ll.file)
927
+ else:
928
+ stmt = self._parse_text_segment(seg_text_raw, ll.line, ll.file)
929
+ if self._folded_include_error(stmt):
930
+ continue
931
+ stmt.at_eol = at_eol and not getattr(stmt, "grouped", False)
932
+ if self.provenance:
933
+ stmt.origin = (ll, seg_offsets[idx]) # positions only: logical line and segment offset
934
+ if (
935
+ isinstance(stmt, S.If)
936
+ and stmt.inline is None
937
+ and idx < last_idx
938
+ and self.profile.name in ("cr6", "cr1000x")
939
+ and re.search(r"\bThen\s*$", seg_text_raw, re.I)
940
+ ):
941
+ stmt.inline = [] # `If X Then : Y = 1` is a one-line If (oracle, 2026-10-10)
942
+ colon_if = True
943
+ # Segments after a one-line `If` belong to its body: no newline in the echo (oracle,
944
+ # CR300, 2026-10-01: `If X Then X = 1 : Foo(1)`).
945
+ in_inline_if = in_inline_if or (isinstance(stmt, S.If) and stmt.inline is not None)
946
+ self.program.statements.append(stmt)
947
+ if isinstance(stmt, S.If) and stmt.inline is not None:
948
+ self.program.statements.extend(_hoist_block_statements(stmt))
949
+ if isinstance(stmt, S.Unparsed) and self.profile.single_pass:
950
+ self.program.statements.extend(self._inline_body_after_bad_condition(stmt, seg_text_raw))
951
+ # A `Const` just parsed is folded into `self.env` inside `_parse_const` itself, so
952
+ # later statements/#If on the same or a later line see it.
953
+ if isinstance(stmt, S.Instruction) and stmt.keyword == "ENDPROG":
954
+ self._done = True
955
+ return
956
+
957
+ def _folded_include_error(self, stmt: S.Stmt) -> bool:
958
+ """Include names missed by the literal loader: bounded constant-name diagnostics.
959
+
960
+ Parsing-time, with prior constants and active branches only. Evidence and exclusions:
961
+ docs/checker.md#folded-include-filenames-round-43.
962
+ """
963
+ if not isinstance(stmt, S.Instruction) or stmt.keyword not in ("INCLUDE", "INCLUDESECTION"):
964
+ return False
965
+ index = int(stmt.keyword == "INCLUDESECTION")
966
+ if len(stmt.args) <= index or stmt.args[index] is None:
967
+ return False
968
+ arg = stmt.args[index]
969
+ try:
970
+ value = consteval.value_of(arg, self.env)
971
+ except consteval.NotConstant, TypeError, ValueError, ZeroDivisionError:
972
+ if isinstance(arg, S.Name) and arg.name.upper() in ("NAN", "INF"):
973
+ value = "-nan" if arg.name.upper() == "NAN" else "inf"
974
+ else:
975
+ return self._runtime_include_name(stmt, index)
976
+ if len(stmt.args) != index + 1:
977
+ return False
978
+ if isinstance(value, str):
979
+ spec = value
980
+ elif isinstance(value, (int, float)):
981
+ if isinstance(arg, S.Number) and arg.text.upper().startswith(("&H", "&B")) and 2**31 <= value < 2**32:
982
+ value -= 2**32
983
+ if not -(2**31) <= value < 2**31 and not consteval.is_float(arg, self.env):
984
+ return False # overflow conversions are not modelled here
985
+ spec = format(value, ".7g")
986
+ else:
987
+ return False
988
+ bad = source.bad_include_extension(
989
+ spec, _INCLUDE_OWN_EXT.get(self.profile.name), _CR3XX_INCLUDE_EXTS.get(self.profile.name)
990
+ )
991
+ if bad:
992
+ self.diags.error(stmt.line, f"Include file {spec} must have valid program file extension.", stmt.file)
993
+ self.program.include_error_at.append(len(self.program.statements))
994
+ elif self.profile.single_pass and not spec.upper().endswith((".CRB", *_CR3XX_INCLUDE_EXTS[self.profile.name])):
995
+ self.diags.warning(stmt.line, f"Cannot open include file {spec}.", stmt.file)
996
+ else:
997
+ return False # loading files named by expressions is outside this bounded rule
998
+ return True
999
+
1000
+ def _runtime_include_name(self, stmt: S.Instruction, index: int) -> bool:
1001
+ """A file name that is not a constant: undeclared names (declared later counts) and
1002
+ variables or calls are rejected, then the name is NULL. Oracle, all four loggers, 2026-10-27:
1003
+ docs/checker.md#include-names-that-are-not-constants-round-53."""
1004
+ from .checker import expressions, symbols
1005
+
1006
+ declared = {n.upper() for n in symbols.declared_names(self.program)}
1007
+ names: list[tuple[str, bool]] = [] # (name, is a call)
1008
+
1009
+ def walk(e) -> None:
1010
+ if isinstance(e, (S.Name, S.Index)):
1011
+ names.append((e.name, isinstance(e, S.Index)))
1012
+ for v in vars(e).values() if isinstance(e, S.Expr) else ():
1013
+ for x in v if isinstance(v, list) else [v]:
1014
+ if isinstance(x, S.Expr):
1015
+ walk(x)
1016
+
1017
+ walk(stmt.args[index])
1018
+ known = {n.upper() for n in (*self.env.consts, *self.profile.symbols, *self.profile.functions)} | declared
1019
+ undeclared = [n for n, call in names if n.upper() not in known]
1020
+ runtime = any(
1021
+ n.upper() in declared or call
1022
+ for n, call in names
1023
+ if n.upper() in known and n.upper() not in self.env.consts
1024
+ )
1025
+ text = expressions._arg_text(stmt, index).strip()
1026
+ if not undeclared and not runtime:
1027
+ return False
1028
+ if self.profile.single_pass:
1029
+ for n in undeclared:
1030
+ self.diags.error(stmt.line, f"{n} is not defined.", stmt.file)
1031
+ self.diags.error(stmt.line, f"Variable illegal in Parameter: {text}.", stmt.file)
1032
+ self.diags.warning(stmt.line, "Cannot open include file .", stmt.file)
1033
+ return True
1034
+ if undeclared:
1035
+ self.diags.error(stmt.line, f"Invalid symbol in expression: {text}.", stmt.file)
1036
+ if runtime:
1037
+ self.diags.error(stmt.line, f"Variable illegal in Parameter: {text}.", stmt.file)
1038
+ self.diags.error(stmt.line, "Include file NULL must have valid program file extension.", stmt.file)
1039
+ self.program.include_error_at.append(len(self.program.statements))
1040
+ return True
1041
+
1042
+ def _inline_body_after_bad_condition(self, stmt: S.Unparsed, seg_text_raw: str) -> list[S.Stmt]:
1043
+ """CR300/CR350: `If X Y Then Y = Nope` reports the unreadable condition and still checks the
1044
+ one-line body (oracle, 2026-10-10: `Nope is not defined.` follows). Returns the body's
1045
+ statements, parsed as if the condition were fine."""
1046
+ m = re.match(r"(?is)\s*If\b.*?\bThen\b(\s*\S.*)$", seg_text_raw)
1047
+ if not m or not stmt.reason.startswith("expected THEN"):
1048
+ return []
1049
+ probe = self._parse_text_segment("If 1 Then" + m.group(1), stmt.line, stmt.file)
1050
+ if not isinstance(probe, S.If) or not probe.inline:
1051
+ return []
1052
+ body = list(probe.inline) + list(probe.inline_else or [])
1053
+ for st in body:
1054
+ st.at_eol = False
1055
+ return body
1056
+
1057
+ def _parse_text_segment(self, seg_text_raw: str, line: int, file: str) -> S.Stmt:
1058
+ try:
1059
+ tokens = lexer.tokenize(seg_text_raw, line)
1060
+ except lexer.LexError as e:
1061
+ if re.match(r"\s*ESSInitialize\b", seg_text_raw, re.I):
1062
+ # Its argument is read raw: `ESSInitialize(public")` and `ESSInitialize("public)`
1063
+ # compile on all four loggers (oracle, 2026-09-28; a fuzz-found false reject).
1064
+ return S.Unparsed(line, file, text=seg_text_raw.strip(), reason="empty statement")
1065
+ m = re.match(r"\s*([A-Za-z_]\w*)", seg_text_raw)
1066
+ rec = self.profile.instructions.get(m.group(1).upper()) if m else None
1067
+ real = [c for c in rec["params"] if c not in (14, 32, 36, 46)] if rec else []
1068
+ slow = bool(real) and m is not None and m.group(1).upper() == "SLOWSEQUENCE"
1069
+ # `SlowSequence` takes only the simple `SlowSequence(")` / `SlowSequence(A,")` shapes here.
1070
+ slow_shape = re.fullmatch(r"\s*SlowSequence\s*\(\s*", seg_text_raw[: e.col], re.I) or re.fullmatch(
1071
+ r"\s*SlowSequence\s*\(\s*[^(),]+,\s*", seg_text_raw[: e.col], re.I
1072
+ )
1073
+ if (
1074
+ e.message == "unterminated string"
1075
+ and real
1076
+ and real[0] >= 100
1077
+ and (not slow or slow_shape)
1078
+ and seg_text_raw[e.col : e.col + 1] == '"'
1079
+ ):
1080
+ if slow and "," in seg_text_raw[: e.col]:
1081
+ # `SlowSequence(A,")`: the cut-off string is a second, excess argument. `0` stands
1082
+ # in for it so the argument count is one too many, and the raw text (quote
1083
+ # included) is kept for the echo (oracle, all four loggers, 2026-10-16:
1084
+ # `Too many parameters: ".`).
1085
+ stmt = self._parse_text_segment(seg_text_raw[: e.col] + "0)", line, file)
1086
+ if isinstance(stmt, S.Instruction):
1087
+ stmt.text = seg_text_raw
1088
+ return stmt
1089
+ # An optional first parameter: the cut-off string is dropped and the rest read as
1090
+ # the instruction (oracle, 2026-10-16: `Calibrate(")` compiles, `Calibrate(A,")`
1091
+ # reports `Variable A out of bounds.`; `SlowSequence(")` compiles, the same).
1092
+ return self._parse_text_segment(seg_text_raw[: e.col].rstrip().rstrip(","), line, file)
1093
+ if m := re.match(r"(\s*Const\s+([A-Za-z_]\w*)\s+As\s+)([^\s,]*\[[^\s,]*)", seg_text_raw, re.I):
1094
+ # a `[` in the type word: the word is the type (`Long[5] unknown Const type`); with
1095
+ # no `=` it is the plain no-assignment pair (oracle, 2026-10-18, "Round 20")
1096
+ if "=" not in seg_text_raw:
1097
+ return self._parse_text_segment(f"Const {m[2]}", line, file)
1098
+ self.env.define(m[2], 0, False)
1099
+ return S.ConstDecl(
1100
+ line,
1101
+ file,
1102
+ name=m[2],
1103
+ value=S.Number(line, file, value=0, text="0"),
1104
+ type=m[3],
1105
+ value_span=(0, 0),
1106
+ text=seg_text_raw,
1107
+ )
1108
+ if e.message == "unterminated string" and re.match(r"\s*Const\s+\"", seg_text_raw, re.I):
1109
+ # `Const "abc`: the string is read as a name, to the next space (oracle, 2026-10-18)
1110
+ word_match = re.match(r"[^\s,]+", seg_text_raw[e.col :])
1111
+ assert word_match is not None
1112
+ word = word_match.group()
1113
+ return S.Unparsed(
1114
+ line,
1115
+ file,
1116
+ text="",
1117
+ reason="Const lead: "
1118
+ + (
1119
+ f"Invalid variable or constant name: {word}."
1120
+ if "=" in seg_text_raw
1121
+ else f"Const with no assignment.\nToo many parameters: {word}."
1122
+ ),
1123
+ )
1124
+ if (
1125
+ e.message == "unterminated string"
1126
+ and "=" not in seg_text_raw
1127
+ and re.fullmatch(r"\s*Const\s+[^\s\",]+\s+", seg_text_raw[: e.col], re.I)
1128
+ ):
1129
+ return self._parse_text_segment(seg_text_raw[: e.col].rstrip(), line, file) # `Const 5 "abc`
1130
+ if e.message == "unterminated string" and re.match(r"\s*Const\s+[A-Za-z_]", seg_text_raw, re.I):
1131
+ if (stmt := self._const_unterminated(seg_text_raw, e.col, line, file)) is not None:
1132
+ return stmt
1133
+ # An unterminated string is echoed verbatim, indentation and trailing spaces included
1134
+ # (oracle, 2026-09-28: ` S = "abc .`).
1135
+ text = seg_text_raw if e.message == "unterminated string" else seg_text_raw.strip()
1136
+ return S.Unparsed(line, file, text=text, reason=f"lex error: {e.message}")
1137
+ real = [t for t in tokens if t.kind != "EOF"]
1138
+ if not real or all(t.kind == "OP" and t.text in "()," for t in real):
1139
+ # A segment of only `(`, `)` and `,` compiles silently on all four loggers
1140
+ # (`docs/checker.md` "Pass-1 fallback: statements that start with a known word").
1141
+ return S.Unparsed(line, file, text=seg_text_raw.strip(), reason="empty statement")
1142
+ seg_text = self._segment_text(seg_text_raw, real)
1143
+ tokens = _drop_semicolons(tokens, self.profile)
1144
+ tokens = _close_open_call(tokens, self.profile)
1145
+ dropped = getattr(tokens, "dropped", None)
1146
+ stmt = self._parse_segment(tokens, line, file, seg_text)
1147
+ if dropped:
1148
+ stmt.dropped_tail = dropped
1149
+ return stmt
1150
+
1151
+ @staticmethod
1152
+ def _segment_text(line_text: str, real_tokens: list[Token]) -> str:
1153
+ # The real compiler's own "Invalid, or out of place expression: %s." (`docs/checker.md`)
1154
+ # echoes the segment's raw source text verbatim -- leading indentation and any trailing
1155
+ # whitespace before a stripped-off `'` comment both preserved, oracle-confirmed
1156
+ # (`udpsocketopen_1.CR6` etc.: a 4-space-indented `UDPSocketOpen(...)` followed by a
1157
+ # comment is echoed as ` UDPSocketOpen(SockID, 0, 3) .`, not the token-trimmed form an
1158
+ # earlier version of this function returned). `real_tokens` is unused now but kept in the
1159
+ # signature since callers already have it computed.
1160
+ del real_tokens
1161
+ return line_text
1162
+
1163
+ def _cr3xx_argument_semicolon(self, seg: list[Token], line: int, file: str, seg_text: str) -> list[Token]:
1164
+ """CR300/CR350: a `;` inside an instruction's argument is `Invalid symbol in expression:
1165
+ <argument up to the ;>.`; an argument at a parameter that ends the argument list
1166
+ (`checker.cr3xx._ABORTING_PARAMS`) adds `Too many parameters: <next argument>.`. The
1167
+ statement is otherwise read without the `;` (oracle, 2026-10-12: `Delay(0,10 ;,mSec)`,
1168
+ `PortSet(C1 ;,1)`, `Sample(1,X ;,FP2)`, `Scan (1 ;,Sec,3,0)`)."""
1169
+ if (
1170
+ len(seg) < 4
1171
+ or seg[0].kind != "IDENT"
1172
+ or seg[1].text != "("
1173
+ or seg[0].upper not in self.profile.instructions
1174
+ or self.profile.instructions[seg[0].upper]["params"][:1] == [7]
1175
+ ):
1176
+ return seg # (code 7, `Case (1 ;)`/`If (c ;)`: a parenthesised expression)
1177
+ depth, index, start = 1, 0, 2
1178
+ for i in range(2, len(seg)):
1179
+ t = seg[i]
1180
+ if t.kind != "OP":
1181
+ continue
1182
+ if t.text == "(":
1183
+ depth += 1
1184
+ elif t.text == ")":
1185
+ depth -= 1
1186
+ if depth == 0:
1187
+ return seg
1188
+ elif t.text == "," and depth == 1:
1189
+ index, start = index + 1, i + 1
1190
+ elif t.text == ";" and depth == 1 and i > start:
1191
+ break
1192
+ else:
1193
+ return seg
1194
+ from .checker.cr3xx import _ABORTING_PARAMS
1195
+
1196
+ table = seg[0].upper == "DATATABLE" # (its errors start the cascade: oracle, 2026-10-12)
1197
+ if table and index == 0 and i == 3 and seg[2].kind == "IDENT":
1198
+ if t.col == seg[2].col + len(seg[2].text):
1199
+ return seg # `DataTable (T;,...)` names the table `T;` (`_parse_segment`)
1200
+ index, symbol = 1, ";" # `DataTable (T ;,1,-1)`: the `;` takes the next slot
1201
+ else:
1202
+ symbol = seg_text[seg[start].col : t.col + 1].strip()
1203
+
1204
+ def error(message: str) -> None:
1205
+ self.diags.items.append(Diagnostic("error", line, message, file, trigger=table))
1206
+
1207
+ error(f"Invalid symbol in expression: {symbol}.")
1208
+ params = self.profile.instructions[seg[0].upper]["params"]
1209
+ if index < len(params) and params[index] in _ABORTING_PARAMS:
1210
+ depth = 1
1211
+ for j in range(i + 1, len(seg)):
1212
+ u = seg[j]
1213
+ if u.kind == "OP" and u.text in "()":
1214
+ depth += 1 if u.text == "(" else -1
1215
+ if u.kind == "OP" and u.text == "," and depth == 1:
1216
+ m = re.match(r"\s*([^\s,()]+)", seg_text[u.col + 1 :])
1217
+ if m:
1218
+ error(f"Too many parameters: {m.group(1)}.")
1219
+ break
1220
+ if depth == 0:
1221
+ break
1222
+ # the list has ended: later arguments are not reported again (`DataTable (T ;,1,-1,0)`)
1223
+ depth, commas = 0, 0
1224
+ for j, u in enumerate(seg):
1225
+ if u.kind == "OP" and u.text in "()":
1226
+ depth += 1 if u.text == "(" else -1
1227
+ elif u.kind == "OP" and u.text == "," and depth == 1:
1228
+ commas += 1
1229
+ if commas == len(params):
1230
+ close = next(
1231
+ k for k in range(len(seg) - 1, j, -1) if seg[k].kind == "OP" and seg[k].text == ")"
1232
+ )
1233
+ seg = seg[:j] + seg[close:]
1234
+ break
1235
+ return [u for u in seg if u is not t]
1236
+
1237
+ def _grouped_statement(self, seg: list[Token], line: int, file: str, seg_text: str) -> S.Stmt | None:
1238
+ """`(w = 0)`, `(Delay(0,1,mSec))`: one pair of parentheses around a whole assignment or
1239
+ instruction compiles as that statement on all four loggers, alone or as a one-line `If`
1240
+ body; `(w)` and `(w + 1)` stay errors (oracle, 2026-10-24, `docs/checker.md` "Parenthesized
1241
+ statements")."""
1242
+ real = [t for t in seg if t.kind != "EOF"]
1243
+ if not (
1244
+ len(real) > 2
1245
+ and real[0].kind == "OP"
1246
+ and real[0].text == "("
1247
+ and real[-1].kind == "OP"
1248
+ and real[-1].text == ")"
1249
+ ):
1250
+ return None
1251
+ depth = 0
1252
+ for k, t in enumerate(real):
1253
+ if t.kind == "OP" and t.text in "()":
1254
+ depth += 1 if t.text == "(" else -1
1255
+ if depth == 0 and k != len(real) - 1:
1256
+ return None # `(a)(b)`: the first group closes before the end
1257
+ kept = len(self.diags.items)
1258
+ inner = self._parse_segment(real[1:-1] + [t for t in seg if t.kind == "EOF"], line, file, seg_text)
1259
+ if isinstance(inner, (S.Assignment, S.Instruction)):
1260
+ if self.profile.single_pass:
1261
+ # CR300/CR350 echo the inside of the group (`Invalid assignment in expression:
1262
+ # Double = Z.`, `paren_assignment.json`)
1263
+ inner.text = (
1264
+ " ".join(t.text for t in real[1:-1])
1265
+ if not seg_text
1266
+ else seg_text[seg_text.index("(") + 1 : seg_text.rindex(")")].strip()
1267
+ )
1268
+ inner.grouped = True # ... with no line break before the `.`
1269
+ return inner
1270
+ del self.diags.items[kept:] # the whole group is reparsed as written
1271
+ return None
1272
+
1273
+ def _parse_segment(self, seg: list[Token], line: int, file: str, seg_text: str) -> S.Stmt:
1274
+ if (grouped := self._grouped_statement(seg, line, file, seg_text)) is not None:
1275
+ return grouped
1276
+ if self.profile.single_pass:
1277
+ seg = self._cr3xx_argument_semicolon(seg, line, file, seg_text)
1278
+ if (
1279
+ len(seg) > 3
1280
+ and seg[0].upper == "DATATABLE"
1281
+ and seg[1].text == "("
1282
+ and seg[2].kind == "IDENT"
1283
+ and seg[3].kind == "OP"
1284
+ and seg[3].text == ";"
1285
+ ):
1286
+ # CR6/CR1000X (`_drop_semicolons` keeps only this `;`): a glued `T;` is the table name
1287
+ # `T;` (`CallTable T` is then `Could not find DataTable T.`); `T ;` makes the `;` the
1288
+ # next parameter (`Invalid, or out of place expression: ;.`, `Too many parameters:
1289
+ # -1.`), like a second word below (oracle, 2026-10-12)
1290
+ if seg[3].col == seg[2].col + len(seg[2].text):
1291
+ name = seg[2].text + ";"
1292
+ seg = seg[:2] + [lexer.Token("IDENT", name, name.upper(), seg[2].col, seg[2].line)] + seg[4:]
1293
+ else:
1294
+ self.diags.error(line, "Invalid, or out of place expression: ;.", file)
1295
+ seg = (
1296
+ seg[:3]
1297
+ + [
1298
+ lexer.Token("OP", ",", ",", seg[3].col, seg[3].line),
1299
+ lexer.Token("NUMBER", "0", "0", seg[3].col, seg[3].line, 0),
1300
+ ]
1301
+ + seg[4:]
1302
+ )
1303
+ if (
1304
+ len(seg) > 3
1305
+ and seg[0].kind == "IDENT"
1306
+ and seg[0].upper in _RAW_NAME_FIRST
1307
+ and seg[1].kind == "OP"
1308
+ and seg[1].text == "("
1309
+ and seg[2].kind == "IDENT"
1310
+ and seg[3].kind == "IDENT"
1311
+ ):
1312
+ # The table name is read up to whitespace too, so a second word becomes the next
1313
+ # parameter: `DataTable (Ten Sec,True,-1)` names `Ten` and reports `Too many
1314
+ # parameters: -1.`; `ConstTable (my Tab,0)` compiles (oracle, 2026-10-01)
1315
+ seg = seg[:3] + [lexer.Token("OP", ",", ",", seg[3].col, seg[3].line)] + seg[3:]
1316
+ if (
1317
+ len(seg) > 2
1318
+ and seg[0].kind == "IDENT"
1319
+ and seg[0].upper == "CALLTABLE"
1320
+ and (
1321
+ seg[1].kind == "NUMBER"
1322
+ or seg[1].kind == "OP"
1323
+ and seg[1].text == "("
1324
+ and len(seg) > 3
1325
+ and seg[2].kind == "NUMBER"
1326
+ )
1327
+ ):
1328
+ at = 1 if seg[1].kind == "NUMBER" else 2 # `CallTable 15Min` / `CallTable(15Min)`
1329
+ m = _DIGIT_NAME.match(seg_text, seg[at].col)
1330
+ if m:
1331
+ rest = [t for t in seg[at + 1 :] if t.col >= m.end()]
1332
+ seg = (
1333
+ seg[:at] + [lexer.Token("IDENT", m.group(0), m.group(0).upper(), seg[at].col, seg[at].line)] + rest
1334
+ )
1335
+ if (
1336
+ len(seg) > 3
1337
+ and seg[0].kind == "IDENT"
1338
+ and seg[0].upper == "DATATABLE"
1339
+ and seg[1].kind == "OP"
1340
+ and seg[1].text == "("
1341
+ and seg[2].kind in ("NUMBER", "STRING")
1342
+ ):
1343
+ # a table name starting with a digit (`DataTable(15Min,...)`, `("15Min")`) is one name,
1344
+ # reported by the checker (oracle, all four loggers, 2026-10-11); one with a dot
1345
+ # (`1.5`, `.5`, `1.5x`, `"1.5"`) is read whole too (`DataTable name 1.5 is illegal.`)
1346
+ m = _DIGIT_NAME.match(seg_text, seg[2].col)
1347
+ if seg[2].kind == "STRING" and _DIGIT_NAME.fullmatch(str(seg[2].value)):
1348
+ name, end = str(seg[2].value), seg[2].col + len(seg[2].text)
1349
+ elif m and seg[2].kind == "NUMBER":
1350
+ name, end = m.group(0), m.end()
1351
+ else:
1352
+ name = end = None
1353
+ if name and end is not None:
1354
+ rest = [t for t in seg[3:] if t.col >= end]
1355
+ seg = seg[:2] + [lexer.Token("IDENT", name, name.upper(), seg[2].col, seg[2].line)] + rest
1356
+ p = _TokStream(seg, seg_text)
1357
+ try:
1358
+ if self._in_struct and seg and seg[0].kind == "IDENT" and seg[0].upper not in self.profile.instructions:
1359
+ # StructureType members: `Name`, `A, B, C`, `Name As Double`,
1360
+ # `Arr(2) As String * 110` -- a declaration list without Public/Dim (the real
1361
+ # compilers accept bare members; structuretype_3.CR6 on CR1000X).
1362
+ vars_ = [self._parse_vardecl(p, line, file)]
1363
+ while p.is_op(","):
1364
+ p.next()
1365
+ if p.at_end():
1366
+ break
1367
+ vars_.append(self._parse_vardecl(p, line, file))
1368
+ stmt = S.Declaration(line, file, kind="STRUCTUREMEMBER", vars=vars_)
1369
+ else:
1370
+ stmt = self._parse_stmt(p, line, file, seg_text)
1371
+ if isinstance(stmt, S.Instruction) and stmt.keyword in ("STRUCTURETYPE", "ENDSTRUCTURETYPE"):
1372
+ self._in_struct = stmt.keyword == "STRUCTURETYPE"
1373
+ if isinstance(stmt, S.Instruction) and stmt.keyword in ("DATATABLE", "ENDTABLE"):
1374
+ self._in_table = stmt.keyword == "DATATABLE"
1375
+ if stmt is None:
1376
+ return S.Unparsed(line, file, text=seg_text, reason="empty statement")
1377
+ if not p.at_end():
1378
+ extra = p.peek()
1379
+ return S.Unparsed(
1380
+ line,
1381
+ file,
1382
+ text=seg_text,
1383
+ reason=f"unexpected trailing input starting at {extra.text or extra.kind!r}",
1384
+ )
1385
+ stmt.text = seg_text
1386
+ return stmt
1387
+ except _ParseError as e:
1388
+ if re.match(r"expected '\)', found '\.\d+'", str(e)) and _SNMP_OID_STATEMENT.match(seg_text):
1389
+ return S.Unparsed(line, file, text=seg_text, reason=SNMP_OID_REASON)
1390
+ if (placed := self._table_only_outside_table(seg, p, line, file, seg_text)) is not None:
1391
+ return placed
1392
+ return S.Unparsed(line, file, text=seg_text, reason=str(e))
1393
+ except Exception as e: # never raise out of the parser
1394
+ return S.Unparsed(line, file, text=seg_text, reason=f"internal error: {e!r}")
1395
+
1396
+ def _table_only_outside_table(
1397
+ self, seg: list[Token], p: _TokStream, line: int, file: str, seg_text: str
1398
+ ) -> S.Instruction | None:
1399
+ """`Sample (1,x y,FP2)` outside a table (oracle, all four loggers, 2026-10-13): the source
1400
+ argument (the second) of the output-processing instructions is not read before `X must be
1401
+ used inside a table declaration.`, so a parse failure inside it, in a balanced argument
1402
+ list, still leaves the instruction; the later arguments become placeholders."""
1403
+ if (
1404
+ self._in_table
1405
+ or self._in_struct
1406
+ or len(seg) < 4
1407
+ or seg[0].kind != "IDENT"
1408
+ or seg[0].upper not in _DEFERRED_SOURCE_INSTRUCTIONS
1409
+ or seg[0].upper not in self.profile.instructions
1410
+ or seg[1].kind != "OP"
1411
+ or seg[1].text != "("
1412
+ ):
1413
+ return None
1414
+ depth, commas = 0, []
1415
+ for i, tok in enumerate(seg[1:], 1):
1416
+ if tok.kind != "OP":
1417
+ continue
1418
+ depth += (tok.text == "(") - (tok.text == ")")
1419
+ if depth < 0 or (depth == 0 and tok.text == ")" and seg[i + 1 :] and seg[i + 1].kind != "EOF"):
1420
+ return None
1421
+ if tok.text == "," and depth == 1:
1422
+ commas.append(i)
1423
+ if depth != 0 or len(commas) < 2 or not commas[0] < p.i <= commas[1]:
1424
+ return None
1425
+ sub = _TokStream(seg[2 : commas[0]] + [Token("EOF", "", "", seg[commas[0]].col)], seg_text)
1426
+ try:
1427
+ first = self._parse_expr(sub, line, file)
1428
+ except _ParseError:
1429
+ return None
1430
+ if not sub.at_end():
1431
+ return None
1432
+ args: list[S.Expr | None] = [first] + [S.Number(line, file, value=0, text="0") for _ in commas]
1433
+ return S.Instruction(line, file, text=seg_text, keyword=seg[0].upper, args=args, arg_spans=[None] * len(args))
1434
+
1435
+ # ------------------------------------------------------- statements
1436
+
1437
+ def _parse_stmt(self, p: _TokStream, line: int, file: str, seg_text: str) -> S.Stmt | None:
1438
+ if p.at_end():
1439
+ return None
1440
+ first = p.peek()
1441
+ if first.kind == "OP" and first.text in ("!", "@"):
1442
+ # `!ptr = ...`: a pointer dereference used as an assignment target (see
1443
+ # docs/frontend.md; pointervariable_3.CR6).
1444
+ return self._parse_assignment_or_call(p, line, file)
1445
+ if first.kind != "IDENT":
1446
+ start = p.i
1447
+ if first.text == "(" and not self.profile.single_pass:
1448
+ # `(Y) = 1`, `(A(1)) += 1`: a parenthesised variable is an assignment target on
1449
+ # CR6/CR1000X (oracle, 2026-10-12); CR300/CR350 reject a leading group
1450
+ try:
1451
+ stmt = self._parse_assignment_or_call(p, line, file)
1452
+ except _ParseError:
1453
+ stmt = None
1454
+ if isinstance(stmt, S.Assignment) and isinstance(stmt.target, (S.Name, S.Index, S.Member)):
1455
+ return stmt
1456
+ p.i = start
1457
+ if (
1458
+ first.kind == "OP"
1459
+ and first.text == "+"
1460
+ and (result := self._try_plus_assign(p, start, line, file)) is not None
1461
+ ):
1462
+ return result # `+ X = 1`
1463
+ p.i = start
1464
+ if first.kind in ("NUMBER", "STRING") or (first.kind == "OP" and first.text in ("(", "-", "+")):
1465
+ if (result := self._try_logical_assign(p, start, line, file)) is not None:
1466
+ return result # `1 And Y = 1`, `-X And Y = 1`
1467
+ p.i = start
1468
+ raise _ParseError(f"statement does not start with an identifier (found {first.text or first.kind!r})")
1469
+ kw = first.upper
1470
+ if kw == "IF":
1471
+ return self._parse_if(p, line, file)
1472
+ if kw == "ELSEIF":
1473
+ return self._parse_elseif(p, line, file)
1474
+ if kw == "FOR":
1475
+ return self._parse_for(p, line, file)
1476
+ if kw in ("DO", "LOOP"):
1477
+ return self._parse_doloop(p, line, file, kw)
1478
+ if kw == "SELECT":
1479
+ return self._parse_select(p, line, file)
1480
+ if kw == "CASE":
1481
+ return self._parse_case(p, line, file)
1482
+ if kw in ("SUB", "FUNCTION"):
1483
+ return self._parse_subdef(p, line, file, kw)
1484
+ if kw == "CONST":
1485
+ return self._parse_const(p, line, file)
1486
+ if kw in ("PUBLIC", "DIM") and p.is_op(".", 1):
1487
+ return self._parse_assignment_or_call(p, line, file) # `Public.X = 1`
1488
+ if kw in ("PUBLIC", "DIM"):
1489
+ return self._parse_declaration(p, line, file, kw)
1490
+ if kw == "ALIAS":
1491
+ return self._parse_alias(p, line, file)
1492
+ if kw == "UNITS":
1493
+ # `_parse_line` intercepts every top-level `:`-segment starting with `Units` before
1494
+ # tokenising it (see `_parse_units_segment`); reached here only for a `Units` nested
1495
+ # inside something already tokenised, such as an inline `If`'s then-clause, which is
1496
+ # not a real CRBasic pattern.
1497
+ raise _ParseError("Units nested inside another statement is not supported")
1498
+ if kw == "CALL":
1499
+ return self._parse_call(p, line, file)
1500
+ if kw in self.profile.instructions:
1501
+ return self._parse_generic_instruction(p, line, file, kw)
1502
+ return self._parse_assignment_or_call(p, line, file)
1503
+
1504
+ def _parse_generic_instruction(self, p: _TokStream, line: int, file: str, kw: str) -> S.Stmt:
1505
+ outer, self._to_ok = self._to_ok, True # `To` in arguments compiles (docs/checker.md)
1506
+ try:
1507
+ return self._parse_generic_instruction_args(p, line, file, kw)
1508
+ finally:
1509
+ self._to_ok = outer
1510
+
1511
+ def _parse_generic_instruction_args(self, p: _TokStream, line: int, file: str, kw: str) -> S.Stmt:
1512
+ p.next()
1513
+ args: list[S.Expr | None] = []
1514
+ spans: list[tuple[int, int] | None] = []
1515
+ if p.is_op("("):
1516
+ p.next()
1517
+ # `strict=False`: an instruction's own argument list left open to end-of-line
1518
+ # silently completes right there, oracle-confirmed (`docs/checker.md` "Need closing )
1519
+ # shapes"), unlike a call reached through the general expression grammar.
1520
+ args, spans = self._parse_arglist(p, ")", line, file, strict=False)
1521
+ self._absorb_trailing(p, args, spans, line, file, kw)
1522
+ elif p.is_op("="):
1523
+ # A handful of instructions (`StationName=Value`) take `=Value` instead of an
1524
+ # argument list, confirmed valid CRBasic by corpus testing; see docs/frontend.md.
1525
+ p.next()
1526
+ args, spans = self._parse_bare_arglist(p, line, file)
1527
+ elif kw == "READONLY":
1528
+ # names split by commas or blanks; empty items are ignored (oracle, all four loggers,
1529
+ # 2026-10-03: `ReadOnly A,`, `ReadOnly A,,B`, `ReadOnly A B` compile)
1530
+ while not p.at_end():
1531
+ if p.is_op(","):
1532
+ p.next()
1533
+ continue
1534
+ start_col = p.peek().col
1535
+ args.append(self._parse_expr(p, line, file))
1536
+ spans.append((start_col, p.peek().col))
1537
+ elif not p.at_end():
1538
+ args, spans = self._parse_bare_arglist(p, line, file)
1539
+ if p.is_op(";") and kw == "EXIT" and not self._inline_body:
1540
+ self._absorb_trailing(p, args, spans, line, file, kw)
1541
+ if kw == "END" and len(args) == 1 and isinstance(args[0], S.Name) and args[0].name.upper() == "TABLE":
1542
+ # `End Table` is `EndTable` on all four loggers (oracle, 2026-10-08; docs/fuzz-findings.md).
1543
+ return S.Instruction(line, file, keyword="ENDTABLE")
1544
+ return S.Instruction(line, file, keyword=kw, args=args, arg_spans=spans)
1545
+
1546
+ def _absorb_trailing(self, p: _TokStream, args: list, spans: list, line: int, file: str, kw: str) -> None:
1547
+ """`Instr(a,b) tail`: the real compiler appends one gap-free tail chunk as one more
1548
+ parameter (`PortSet(C1,1) 1` == `PortSet(C1,1,1)`), and ignores stray `)` `,` `=` tokens
1549
+ (oracle, all four loggers, 2026-10-04, `docs/checker.md` "Trailing tokens after an
1550
+ instruction call"). Anything else is left unconsumed and stays a rejected statement."""
1551
+ rest = [t for t in p.remaining() if t.kind != "EOF"]
1552
+ if not rest:
1553
+ return
1554
+ pieces: list[list[Token]] = [[]]
1555
+ for t in rest:
1556
+ if t.kind == "OP" and t.text == ",":
1557
+ pieces.append([])
1558
+ else:
1559
+ pieces[-1].append(t)
1560
+ params = self.profile.instructions[kw]["params"]
1561
+ found: list[tuple[S.Expr, tuple[int, int]]] = []
1562
+ if len(args) >= len(params) and rest[0].kind == "OP" and rest[0].text == ";" and not self._inline_body:
1563
+ # `Battery(X) ;`: a `;` after the last parameter is one more (gap-free) parameter
1564
+ # (`Too many parameters: ;.`, oracle, all four loggers, 2026-10-12)
1565
+ end = 1
1566
+ while (
1567
+ end < len(rest)
1568
+ and rest[end].kind == "OP"
1569
+ and rest[end].text == ";"
1570
+ and rest[end].col == rest[end - 1].col + 1
1571
+ ):
1572
+ end += 1
1573
+ text = ";" * end
1574
+ args.append(S.Name(line, file, name=text))
1575
+ spans.append((rest[0].col, rest[0].col + end))
1576
+ p.i = len(p.toks) - 1
1577
+ return
1578
+ for piece in pieces:
1579
+ slot = len(args) + len(found)
1580
+ const_slot = slot >= len(params) or params[slot] in (4, 104)
1581
+ got = self._tail_chunk(piece, p.seg_text, line, file, const_slot)
1582
+ if got is None and const_slot:
1583
+ # a constant-integer slot, or one past the last: whitespace separates parameters
1584
+ # (`PortSet(C1,1) 1 2` -> `Too many parameters: 2.`, `docs/checker.md`)
1585
+ groups: list[list[Token]] = [[piece[0]]] if piece else []
1586
+ for a, b in zip(piece, piece[1:]):
1587
+ if b.col == a.col + len(a.text):
1588
+ groups[-1].append(b)
1589
+ else:
1590
+ groups.append([b])
1591
+ got = [self._tail_chunk(g, p.seg_text, line, file, True) for g in groups]
1592
+ if not got or None in got:
1593
+ return
1594
+ found.extend(g for g in got if isinstance(g, tuple))
1595
+ continue
1596
+ if got is None and piece:
1597
+ # an expression slot reads the whole piece as one expression (`Delay(1,1) 1 +2`)
1598
+ got = self._whole_expr(piece, p.seg_text, line, file)
1599
+ if got is None:
1600
+ return
1601
+ if got is not True:
1602
+ found.append(got)
1603
+ for expr, span in found:
1604
+ args.append(expr)
1605
+ spans.append(span)
1606
+ p.i = len(p.toks) - 1
1607
+
1608
+ def _absorb_sub_tail(self, p: _TokStream, args: list, line: int, file: str) -> bool:
1609
+ """`S(a) tail` (user `Sub` call): the tail is more comma-separated arguments (oracle, all
1610
+ four loggers, 2026-10-04, `docs/checker.md` "Trailing tokens after a Sub call"). Modelled:
1611
+ pieces that are one complete expression (gaps allowed, or one with a stray closing
1612
+ `)`); empty pieces count except a final one; a lone `)` or `,` is one empty argument. Anything else stays
1613
+ unparsed. After empty parentheses `S()` the whole tail is ignored (returns True)."""
1614
+ rest = [t for t in p.remaining() if t.kind != "EOF"]
1615
+ if not rest:
1616
+ return False
1617
+ if not args:
1618
+ p.i = len(p.toks) - 1
1619
+ return True
1620
+ pieces: list[list[Token]] = [[]]
1621
+ depth = 0
1622
+ for t in rest:
1623
+ if t.kind == "OP" and t.text == "(":
1624
+ depth += 1
1625
+ elif t.kind == "OP" and t.text == ")":
1626
+ depth = max(0, depth - 1)
1627
+ if t.kind == "OP" and t.text == "," and depth == 0:
1628
+ pieces.append([])
1629
+ else:
1630
+ pieces[-1].append(t)
1631
+ if not pieces[-1]:
1632
+ pieces.pop()
1633
+ found: list[S.Expr | None] = []
1634
+ for piece in pieces:
1635
+ if not piece:
1636
+ found.append(None)
1637
+ elif len(piece) == 1 and piece[0].text == ")":
1638
+ if len(pieces) > 1:
1639
+ return False
1640
+ found.append(None)
1641
+ else:
1642
+ got = self._whole_expr(piece, p.seg_text, line, file)
1643
+ if got is None:
1644
+ return False
1645
+ found.append(got[0])
1646
+ args.extend(found)
1647
+ p.i = len(p.toks) - 1
1648
+ return False
1649
+
1650
+ def _whole_expr(self, piece: list[Token], seg_text: str, line: int, file: str):
1651
+ """`piece` (gaps allowed) as one expression, or with one stray trailing `)` dropped ->
1652
+ `(expr, span)`, else `None`."""
1653
+ for cand in (piece, piece[:-1] if piece[-1].text == ")" else None):
1654
+ if not cand:
1655
+ continue
1656
+ sub = _TokStream(cand + [lexer.Token("EOF", "", "", cand[-1].col)], seg_text)
1657
+ try:
1658
+ expr = self._parse_expr(sub, line, file)
1659
+ except _ParseError:
1660
+ continue
1661
+ if sub.at_end():
1662
+ return expr, (cand[0].col, cand[-1].col + len(cand[-1].text))
1663
+ return None
1664
+
1665
+ def _tail_chunk(self, piece: list[Token], seg_text: str, line: int, file: str, const_slot: bool = False):
1666
+ """One comma-free tail piece -> `(expr, span)`, `True` (only stray `)`/`=`), or `None`."""
1667
+ lead = 0
1668
+ while lead < len(piece) and piece[lead].kind == "OP" and piece[lead].text == "(":
1669
+ lead += 1
1670
+ loose = (
1671
+ [t for t in piece[lead:] if not (t.kind == "OP" and t.text in (")", "="))] if lead and const_slot else []
1672
+ )
1673
+ for chunk in (piece, [t for t in piece if not (t.kind == "OP" and t.text in (")", "="))], loose):
1674
+ if not chunk:
1675
+ if chunk is loose:
1676
+ continue
1677
+ return True
1678
+ if any(a.kind == "STRING" or b.col != a.col + len(a.text) for a, b in zip(chunk, chunk[1:])):
1679
+ continue
1680
+ sub = _TokStream(chunk + [lexer.Token("EOF", "", "", chunk[-1].col)], seg_text)
1681
+ try:
1682
+ expr = self._parse_expr(sub, line, file)
1683
+ except _ParseError:
1684
+ continue
1685
+ if sub.at_end():
1686
+ return expr, (chunk[0].col, chunk[-1].col + len(chunk[-1].text))
1687
+ return None
1688
+
1689
+ def _parse_select(self, p: _TokStream, line: int, file: str) -> S.Stmt:
1690
+ p.next() # SELECT
1691
+ p.expect_ident("CASE")
1692
+ self._to_ok = True
1693
+ try:
1694
+ expr = self._parse_expr(p, line, file)
1695
+ finally:
1696
+ self._to_ok = False
1697
+ return S.Instruction(line, file, keyword="SELECT", args=[expr])
1698
+
1699
+ def _parse_call(self, p: _TokStream, line: int, file: str) -> S.Stmt:
1700
+ p.next() # CALL
1701
+ name_tok = p.expect_ident()
1702
+ j = p.i
1703
+ while p.toks[j].kind == "IDENT":
1704
+ j += 1
1705
+ if j > p.i and (p.toks[j].kind == "EOF" or (p.toks[j].kind == "OP" and p.toks[j].text == "(")):
1706
+ # spaces inside the Sub name are dropped: `Call r un(A,B)` calls `run` (oracle, all
1707
+ # four loggers, 2026-10-12; fuzz false reject `cr1000x/false_reject/f4932fe73ffd`)
1708
+ merged = name_tok.text + "".join(t.text for t in p.toks[p.i : j])
1709
+ name_tok = dataclasses.replace(name_tok, text=merged, upper=merged.upper())
1710
+ p.i = j
1711
+ args: list[S.Expr | None] = []
1712
+ if p.is_op("("):
1713
+ p.next()
1714
+ args, _spans = self._parse_arglist(p, ")", line, file, strict=False) # see above
1715
+ if (
1716
+ len(args) > 1
1717
+ and args[-1] is None
1718
+ and not (p.toks[p.i - 1].kind == "OP" and p.toks[p.i - 1].text == ")")
1719
+ ):
1720
+ args.pop() # `Call S(a,b,` (never closed): the empty slot after the last comma is
1721
+ # not an argument (oracle, all four loggers, 2026-10-12)
1722
+ ignored = self._absorb_sub_tail(p, args, line, file)
1723
+ return S.Call(line, file, name=name_tok.text, args=args, explicit=True, tail_ignored=ignored)
1724
+ elif p.is_op(";") and not self._inline_body:
1725
+ # `Call Foo ;` links to the sub named `Foo;` (`structure._call_link_name`)
1726
+ args = [S.Name(line, file, name=";")]
1727
+ p.i = len(p.toks) - 1
1728
+ elif not p.at_end():
1729
+ args, _spans = self._parse_bare_arglist(p, line, file)
1730
+ return S.Call(line, file, name=name_tok.text, args=args, explicit=True)
1731
+
1732
+ def _try_logical_assign(self, p: _TokStream, start: int, line: int, file: str) -> S.Stmt | None:
1733
+ """The `LOGICAL_ASSIGN` reading of the whole statement from token `start`, or None
1734
+ (position restored by the caller)."""
1735
+ p.i = start
1736
+ saved = p.toks
1737
+ n = len(saved) - 1
1738
+ while (
1739
+ self.profile.name in ("cr6", "cr1000x")
1740
+ and n > start
1741
+ and saved[n - 1].kind == "OP"
1742
+ and saved[n - 1].text == ";"
1743
+ ):
1744
+ n -= 1 # a trailing `;` is skipped like a blank on CR6/CR1000X (`X AND Y = 1 ;`)
1745
+ p.toks = saved[:n] + saved[-1:]
1746
+ self._logical_assign = True
1747
+ try:
1748
+ expr = self._parse_expr(p, line, file)
1749
+ except _ParseError:
1750
+ expr = None
1751
+ finally:
1752
+ self._logical_assign = False
1753
+ if expr is not None and p.at_end() and _has_assign(expr):
1754
+ return S.Unparsed(line, file, text="", reason=LOGICAL_ASSIGN, expr=expr)
1755
+ p.toks = saved
1756
+ return None
1757
+
1758
+ def _try_plus_assign(self, p: _TokStream, start: int, line: int, file: str) -> S.Stmt | None:
1759
+ """`+ X = 1`, `+ + X = 1`: leading `+` signs before an assignment target are ignored and
1760
+ the statement compiles as a `LOGICAL_ASSIGN` (an indexed target only on CR6/CR1000X; oracle,
1761
+ all four loggers, round 64, docs/checker.md#leading-plus-before-an-assignment-round-64)."""
1762
+ p.i = start
1763
+ while p.is_op("+"):
1764
+ p.next()
1765
+ if not self._at_assign_target(p) or p.is_op("(") or (self.profile.single_pass and not p.is_op("=", 1)):
1766
+ return None
1767
+ try:
1768
+ target = self._parse_atom(p, line, file)
1769
+ if not p.is_op("="):
1770
+ return None
1771
+ p.next()
1772
+ value = self._parse_expr(p, line, file)
1773
+ except _ParseError:
1774
+ return None
1775
+ if not p.at_end():
1776
+ return None
1777
+ return S.Unparsed(
1778
+ line, file, text="", reason=LOGICAL_ASSIGN, expr=S.Binary(line, file, "ASSIGN", target, value)
1779
+ )
1780
+
1781
+ def _parse_assignment_or_call(self, p: _TokStream, line: int, file: str) -> S.Stmt:
1782
+ # An lvalue only (never a full expression): `=` is also the relational operator, so
1783
+ # going through `_parse_expr` here would swallow it as an equality test instead of
1784
+ # leaving it for the assignment check below (found by corpus testing; see
1785
+ # docs/frontend.md).
1786
+ # `strict=False`: this atom may turn out to be a bare call's own name (`Foo(1,2,3` with
1787
+ # nothing further), which tolerates an unclosed paren at end-of-line just like `Call`/a
1788
+ # recognised instruction (oracle-confirmed) -- but only for *this* atom's own outermost
1789
+ # parens; anything nested inside it is parsed via `_parse_expr` and is strict again.
1790
+ start = p.i
1791
+ if p.is_ident("NOT") and (result := self._try_logical_assign(p, start, line, file)):
1792
+ return result # `Not X And Y = 1`
1793
+ p.i = start
1794
+ outer, self._to_ok = self._to_ok, True # `A(1 To 2) = 3` compiles
1795
+ try:
1796
+ target = self._parse_atom(p, line, file, strict=False)
1797
+ except _ParseError:
1798
+ if (result := self._try_logical_assign(p, start, line, file)) is not None:
1799
+ return result
1800
+ raise
1801
+ finally:
1802
+ self._to_ok = outer
1803
+ t = p.peek()
1804
+ if (
1805
+ t.kind == "OP"
1806
+ and t.text == "="
1807
+ and isinstance(target, S.Index)
1808
+ and not target.args
1809
+ and p.toks[p.i + 1].kind == "EOF"
1810
+ ):
1811
+ # `S2() =`: a Sub call whose tail is ignored (oracle, 2026-10-04, all four loggers)
1812
+ p.i = len(p.toks) - 1
1813
+ return S.Call(line, file, name=target.name, args=[], explicit=False, tail_ignored=True)
1814
+ if t.kind == "OP" and (t.text == "=" or t.text in _COMPOUND_OPS):
1815
+ op_tok = p.next()
1816
+ self._to_ok = True # `X = 1 To 12`, `X += 1 To 12` (oracle, 2026-10-10)
1817
+ try:
1818
+ value = self._parse_expr(p, line, file)
1819
+ finally:
1820
+ self._to_ok = False
1821
+ if op_tok.text != "=":
1822
+ base_op = op_tok.text[0]
1823
+ value = S.Binary(op_tok.line, file, base_op, target, value)
1824
+ return S.Assignment(line, file, target=target, value=value, compound=op_tok.text != "=")
1825
+ if (p.peek().kind == "IDENT" and p.peek().upper in _WORD_BINARY_OPS - {"MOD"}) or (
1826
+ p.peek().kind == "OP" and p.peek().text in _LOGICAL_LEAD_OPS
1827
+ ):
1828
+ # `X > 2 And Y = 1`, `X + 1 And Y = 1`, `(X > 2) And Y = 1`: any left operand may
1829
+ # lead into the logical-assignment reading (oracle, all four loggers, 2026-10-12)
1830
+ end = p.i
1831
+ if (result := self._try_logical_assign(p, start, line, file)) is not None:
1832
+ return result
1833
+ p.i = end
1834
+ if isinstance(target, S.Name) and p.is_ident("AS") and (self._in_struct or not self.profile.single_pass):
1835
+ # (CR300/CR350 outside a StructureType read `Flag As Boolean` as an expression: oracle,
1836
+ # 2026-10-11, `chm/not_1.CR6`)
1837
+ # A StructureType member declaration (`Frequency As Float`, no Public/Dim prefix):
1838
+ # confirmed valid CRBasic inside StructureType...EndStructureType by corpus testing
1839
+ # (structuretype_1.CR6); see docs/frontend.md. Reuses `Declaration`/`VarDecl` with a
1840
+ # distinct `kind` rather than adding a new node.
1841
+ p.next() # AS
1842
+ type_tok = p.expect_ident()
1843
+ vtype = type_tok.upper
1844
+ if p.is_op("!"):
1845
+ p.next() # pointer type (see docs/frontend.md)
1846
+ assert vtype is not None
1847
+ vtype += "!"
1848
+ strsize = None
1849
+ if vtype == "STRING" and p.is_op("*"):
1850
+ p.next()
1851
+ strsize = self._parse_expr(p, line, file)
1852
+ var = S.VarDecl(name=target.name, dims=[], type=vtype, string_size=strsize, init=None)
1853
+ return S.Declaration(line, file, kind="STRUCTUREMEMBER", vars=[var])
1854
+ if isinstance(target, S.Name):
1855
+ return S.Call(line, file, name=target.name, args=[], explicit=False)
1856
+ if isinstance(target, S.Index) and (
1857
+ target.name.upper() in self.function_names
1858
+ or (
1859
+ self.profile.single_pass
1860
+ and target.name.upper() in self.profile.functions
1861
+ and target.name.upper() not in self.profile.instructions
1862
+ )
1863
+ ):
1864
+ # a `Function` call statement is one expression (oracle, 2026-10-08: `F0()+Nope`
1865
+ # and `Fn(1)+Zork` report the undefined name, `Fn(1)+1` compiles), not a Sub tail
1866
+ if p.at_end():
1867
+ return S.Call(line, file, name=target.name, args=list(target.args), explicit=False)
1868
+ t = p.peek()
1869
+ if (t.kind in ("IDENT", "NUMBER", "STRING") and t.upper not in _WORD_BINARY_OPS) or (
1870
+ t.kind == "OP" and t.text in ("(", ",")
1871
+ ):
1872
+ # a juxtaposed tail (`Fn(1) 1`, `Fn(1),Zork`): `Incomplete expression` (oracle,
1873
+ # 2026-10-08, all four loggers); its operands are still name-checked
1874
+ juxt: list = []
1875
+ comma = t.kind == "OP" # `,` or `(`: tail names are not checked
1876
+ while not p.at_end():
1877
+ if p.peek().kind == "OP" and p.peek().text == ",":
1878
+ p.i += 1
1879
+ continue
1880
+ juxt.append(self._parse_expr(p, line, file))
1881
+ return S.Call(
1882
+ line,
1883
+ file,
1884
+ name=target.name,
1885
+ args=list(target.args),
1886
+ explicit=False,
1887
+ juxtaposed=juxt,
1888
+ juxt_opaque=comma,
1889
+ )
1890
+ p.i = start
1891
+ expr = self._parse_expr(p, line, file)
1892
+ if not p.at_end():
1893
+ t = p.peek()
1894
+ if not (
1895
+ self.profile.single_pass
1896
+ and t.kind in ("IDENT", "NUMBER", "STRING")
1897
+ and t.upper not in _WORD_BINARY_OPS
1898
+ ):
1899
+ raise _ParseError("expected an assignment or a call")
1900
+ # a complete expression, then a juxtaposed operand (`StrComp(a,b) > 0 Then`): CR300/
1901
+ # CR350 (oracle, 2026-10-11) name the first operand, then `Incomplete expression`
1902
+ juxt = []
1903
+ while not p.at_end():
1904
+ juxt.append(self._parse_expr(p, line, file))
1905
+ return S.Call(
1906
+ line,
1907
+ file,
1908
+ name=target.name,
1909
+ args=list(target.args),
1910
+ explicit=False,
1911
+ tail_expr=expr,
1912
+ juxtaposed=juxt,
1913
+ )
1914
+ return S.Call(line, file, name=target.name, args=list(target.args), explicit=False, tail_expr=expr)
1915
+ if isinstance(target, S.Index):
1916
+ args = list(target.args)
1917
+ if (
1918
+ len(args) > 1
1919
+ and args[-1] is None
1920
+ and not (p.toks[p.i - 1].kind == "OP" and p.toks[p.i - 1].text == ")")
1921
+ ):
1922
+ args.pop() # never closed: see `_parse_call`
1923
+ ignored = self._absorb_sub_tail(p, args, line, file)
1924
+ return S.Call(line, file, name=target.name, args=args, explicit=False, tail_ignored=ignored)
1925
+ raise _ParseError("expected an assignment or a call")
1926
+
1927
+ # ---- If / ElseIf ----------------------------------------------------
1928
+
1929
+ def _parse_if(self, p: _TokStream, line: int, file: str) -> S.Stmt:
1930
+ p.next() # IF
1931
+ outer, self._to_ok = self._to_ok, True # `If X To 2 Then` compiles (oracle, round 9)
1932
+ try:
1933
+ cond = self._parse_expr(p, line, file)
1934
+ finally:
1935
+ self._to_ok = outer
1936
+ then_tok = None
1937
+ if p.is_ident("THEN"):
1938
+ then_tok = p.next()
1939
+ elif not p.at_end():
1940
+ # Block-form `If` may omit `Then` entirely (confirmed with the oracle: `If Flag` /
1941
+ # `EndIf` compiles) -- but inline form (something follows on the line) still needs
1942
+ # it, so only tolerate the omission when nothing follows. See docs/frontend.md.
1943
+ raise _ParseError(f"expected THEN, found {p.peek().text or p.peek().kind!r}")
1944
+ if p.at_end():
1945
+ return S.If(line, file, cond=cond, inline=None, inline_else=None)
1946
+ rest = [t for t in p.remaining() if t.kind != "EOF"]
1947
+ p.i = len(p.toks) - 1 # consume the rest of this segment
1948
+ depth = 0
1949
+ else_idx = None
1950
+ for idx, t in enumerate(rest):
1951
+ if t.kind == "OP" and t.text == "(":
1952
+ depth += 1
1953
+ elif t.kind == "OP" and t.text == ")":
1954
+ depth = max(0, depth - 1)
1955
+ elif t.kind == "IDENT" and t.upper == "ELSE" and depth == 0:
1956
+ else_idx = idx
1957
+ break
1958
+ if else_idx is None:
1959
+ then_toks, else_toks = rest, None
1960
+ else:
1961
+ then_toks, else_toks = rest[:else_idx], rest[else_idx + 1 :]
1962
+ # The first statement after `Then`/`Else` is echoed from one column past the keyword to
1963
+ # one column before `Else` or the segment's end, trailing spaces kept (oracle, 2026-10-01).
1964
+ text_end = len(p.seg_text)
1965
+ else_tok = rest[else_idx] if else_idx is not None else None
1966
+ then_bounds = (
1967
+ then_tok.col + len(then_tok.text) + 1 if then_tok else None,
1968
+ else_tok.col - 1 if else_tok else text_end,
1969
+ )
1970
+ then_stmts = self._parse_stmt_list(then_toks, line, file, p.seg_text, then_bounds)
1971
+ inline_else = None
1972
+ if else_toks is not None:
1973
+ assert else_tok is not None
1974
+ inline_else = self._parse_stmt_list(
1975
+ else_toks, line, file, p.seg_text, (else_tok.col + len(else_tok.text) + 1, text_end)
1976
+ )
1977
+ return S.If(line, file, cond=cond, inline=then_stmts, inline_else=inline_else)
1978
+
1979
+ def _parse_stmt_list(
1980
+ self, toks: list[Token], line: int, file: str, outer: str = "", bounds: tuple[int | None, int] | None = None
1981
+ ) -> list[S.Stmt]:
1982
+ """Statements of a one-line `If`. Each gets its verbatim source slice as `text` (the real
1983
+ compiler echoes `X=Q+1`, oracle 2026-09-27) with `arg_spans` re-based onto it, and
1984
+ `at_eol` False (no newline in the CR300/CR350 echo)."""
1985
+ segs = _split_colon_segments(toks + [_eof_like(line)])
1986
+ out = []
1987
+ for seg in segs:
1988
+ real = [t for t in seg if t.kind != "EOF"]
1989
+ if not real:
1990
+ continue
1991
+ start, end = real[0].col, real[-1].col + len(real[-1].text)
1992
+ if bounds and seg is segs[0]:
1993
+ if bounds[0] is not None:
1994
+ start = min(start, bounds[0])
1995
+ end = max(end, bounds[1])
1996
+ verbatim = outer[start:end] if outer and len(outer) >= end else ""
1997
+ self._inline_body = True
1998
+ try:
1999
+ stmt = self._parse_segment(seg, line, file, verbatim or " ".join(t.text for t in real))
2000
+ finally:
2001
+ self._inline_body = False
2002
+ if verbatim:
2003
+ if hasattr(stmt, "text"):
2004
+ stmt.text = verbatim
2005
+ spans = getattr(stmt, "arg_spans", None)
2006
+ if spans:
2007
+ # a span ending at the segment's synthetic EOF (col 0) ends at the segment
2008
+ stmt.arg_spans = [
2009
+ None if sp is None else (sp[0] - start, (sp[1] if sp[1] >= sp[0] else end) - start)
2010
+ for sp in spans
2011
+ ]
2012
+ stmt.at_eol = False
2013
+ out.append(stmt)
2014
+ return out
2015
+
2016
+ def _parse_elseif(self, p: _TokStream, line: int, file: str) -> S.Stmt:
2017
+ p.next() # ELSEIF
2018
+ outer, self._to_ok = self._to_ok, True
2019
+ try:
2020
+ cond = self._parse_expr(p, line, file)
2021
+ finally:
2022
+ self._to_ok = outer
2023
+ if p.is_ident("THEN"):
2024
+ p.next()
2025
+ p.i = len(p.toks) - 1 # whatever follows `Then` on the line is ignored (oracle, 2026-10-12)
2026
+ return S.ElseIf(line, file, cond=cond)
2027
+
2028
+ # ---- For --------------------------------------------------------
2029
+
2030
+ def _parse_for(self, p: _TokStream, line: int, file: str) -> S.Stmt:
2031
+ p.next() # FOR
2032
+ try:
2033
+ var = self._parse_atom(p, line, file) # lvalue only; see `_parse_assignment_or_call`
2034
+ p.expect_op("=")
2035
+ except _ParseError:
2036
+ raise _ParseError("For missing parameter") from None
2037
+ if p.at_end():
2038
+ raise _ParseError("For missing parameter")
2039
+ start = self._parse_expr(p, line, file)
2040
+ if p.at_end():
2041
+ raise _ParseError("For missing parameter")
2042
+ p.expect_ident("TO")
2043
+ if p.at_end():
2044
+ raise _ParseError("For missing parameter")
2045
+ end = self._parse_expr(p, line, file)
2046
+ step = None
2047
+ if p.is_ident("STEP"):
2048
+ p.next()
2049
+ step = self._parse_expr(p, line, file)
2050
+ return S.For(line, file, var=var, start=start, end=end, step=step)
2051
+
2052
+ # ---- Do / Loop ----------------------------------------------------
2053
+
2054
+ def _parse_doloop(self, p: _TokStream, line: int, file: str, which: str) -> S.Stmt:
2055
+ p.next() # DO or LOOP
2056
+ cond_kind = None
2057
+ cond = None
2058
+ if p.is_ident("WHILE") or p.is_ident("UNTIL"):
2059
+ tok = p.next()
2060
+ cond_kind = tok.upper
2061
+ cond = self._parse_expr(p, line, file)
2062
+ elif not p.at_end():
2063
+ # `Do X`/`Loop "a"`: the word is read as an instruction parameter (oracle, 2026-10-10)
2064
+ t = p.peek(1) if p.is_op("(") and p.peek(1).kind == "IDENT" else p.peek()
2065
+ if t.kind in ("IDENT", "STRING"):
2066
+ raise _ParseError(f"Do/Loop parameter: Illegal parameter {t.text}, expecting constant integer.")
2067
+ if (t.kind == "NUMBER" and p.peek(1).kind == "EOF") or (
2068
+ t.kind == "OP" and t.text == "-" and p.peek(1).kind == "NUMBER" and p.peek(2).kind == "EOF"
2069
+ ):
2070
+ raise _ParseError("Do/Loop parameter: Invalid or missing expression.")
2071
+ return S.DoLoop(line, file, keyword=which, cond_kind=cond_kind, cond=cond)
2072
+
2073
+ # ---- Case -----------------------------------------------------------
2074
+
2075
+ def _parse_case(self, p: _TokStream, line: int, file: str) -> S.Stmt:
2076
+ p.next() # CASE
2077
+ if p.is_ident("ELSE") and p.peek(1).kind == "EOF":
2078
+ p.next()
2079
+ return S.Case(line, file, items=[], is_else=True)
2080
+ items: list[S.CaseItem] = []
2081
+ while True:
2082
+ if p.is_ident("IS"):
2083
+ p.next()
2084
+ op_tok = p.next()
2085
+ if not (op_tok.kind == "OP" and op_tok.text in _RELOPS):
2086
+ raise _ParseError(f"expected a comparison after Is, found {op_tok.text!r}")
2087
+ e = self._parse_expr(p, line, file)
2088
+ items.append(("is", op_tok.text, e))
2089
+ else:
2090
+ lo = self._parse_expr(p, line, file)
2091
+ if p.is_ident("TO"):
2092
+ p.next()
2093
+ hi = self._parse_expr(p, line, file)
2094
+ items.append(("range", lo, hi))
2095
+ else:
2096
+ items.append(("value", lo))
2097
+ if p.is_op(","):
2098
+ p.next()
2099
+ continue
2100
+ break
2101
+ return S.Case(line, file, items=items, is_else=False)
2102
+
2103
+ # ---- Sub / Function -------------------------------------------------
2104
+
2105
+ def _parse_subdef(self, p: _TokStream, line: int, file: str, which: str) -> S.Stmt:
2106
+ p.next()
2107
+ name_tok = p.expect_ident()
2108
+ params: list[S.Param] = []
2109
+ errors: list[str] = []
2110
+ if p.is_op("("):
2111
+ p.next()
2112
+ if not p.is_op(")") and not p.at_end():
2113
+ while True:
2114
+ if bad := self._numeric_name(p): # (oracle, all four loggers, round 74: `Sub S(1p)`)
2115
+ errors.append(f"Variable name {bad} illegal, starts with a number.")
2116
+ else:
2117
+ params.append(self._parse_param(p, line, file, errors))
2118
+ if not p.is_op(","):
2119
+ break
2120
+ p.next()
2121
+ if p.at_end():
2122
+ break # a header cut off at a comma is accepted (oracle, 2026-10-12)
2123
+ p.expect_close(")")
2124
+ return_type = None
2125
+ if which == "FUNCTION" and p.is_ident("AS"):
2126
+ p.next()
2127
+ type_tok = p.expect_ident()
2128
+ return_type = type_tok.upper
2129
+ if p.is_op("!"):
2130
+ p.next() # pointer type (see docs/frontend.md)
2131
+ return_type += "!"
2132
+ if return_type == "STRING" and p.is_op("*"):
2133
+ p.next()
2134
+ self._parse_expr(p, line, file) # size; SubDef has no slot for it (see docs/frontend.md)
2135
+ if not p.at_end():
2136
+ # (`As Long * 30`, `As Long Junk`; oracle, CR6/CR1000X, round 59)
2137
+ errors.append(f"Too many parameters: {_raw_word(p, ' ,:()=>')}.")
2138
+ p.i = len(p.toks) - 1
2139
+ elif params and not p.at_end() and p.peek().kind == "IDENT":
2140
+ # Words after `)` other than `As` are read as more parameters: `Function F(x) Ass Long`
2141
+ # -> `Invalid Variable declaration Long.` (oracle, CR6/CR1000X, round 59,
2142
+ # docs/checker.md#malformed-routine-headers-round-59)
2143
+ params.append(self._parse_param(p, line, file, errors))
2144
+ p.i = len(p.toks) - 1
2145
+ return S.SubDef(
2146
+ line, file, keyword=which, name=name_tok.text, params=params, return_type=return_type, header_errors=errors
2147
+ )
2148
+
2149
+ def _parse_param(self, p: _TokStream, line: int, file: str, errors: list[str] | None = None) -> S.Param:
2150
+ optional = p.is_ident("OPTIONAL")
2151
+ if optional:
2152
+ p.next()
2153
+ # Routine headers read a quoted token as the literal declaration name, including
2154
+ # quotes, not as the identifier inside it (oracle, all four loggers, round 45).
2155
+ # Whitespace and parentheses inside the token enter the reference's raw declaration
2156
+ # parser; those shapes are outside this bounded rule.
2157
+ if p.peek().kind == "STRING" and not re.search(r"[\s()]", p.peek().text):
2158
+ name_tok = p.next()
2159
+ else:
2160
+ name_tok = p.expect_ident()
2161
+ dims = 0
2162
+ dim_exprs: list[S.Expr] = []
2163
+ if p.is_op("("):
2164
+ p.next()
2165
+ if not p.is_op(")"):
2166
+ dim_exprs.append(self._parse_dim(p, line, file))
2167
+ while p.is_op(","):
2168
+ p.next()
2169
+ dim_exprs.append(self._parse_dim(p, line, file))
2170
+ p.expect_close(")")
2171
+ dims = 1
2172
+ if errors is not None and p.peek().kind == "IDENT" and not p.is_ident("AS"):
2173
+ # a second word: `Optionl z=0` reports `z=0`, then the rest still parses (oracle,
2174
+ # CR6/CR1000X, round 59)
2175
+ errors.append(f"Invalid Variable declaration {_raw_word(p, ' ,)')}.")
2176
+ p.next()
2177
+ vtype = None
2178
+ strsize = None
2179
+ if p.is_ident("AS"):
2180
+ p.next()
2181
+ type_tok = None if p.at_end() else p.expect_ident() # `b As` cut off at the line end
2182
+ vtype = type_tok.upper if type_tok else None
2183
+ if p.is_op("!"):
2184
+ p.next() # pointer type (see docs/frontend.md)
2185
+ assert vtype is not None
2186
+ vtype += "!"
2187
+ if vtype == "STRING" and p.is_op("*"):
2188
+ p.next()
2189
+ size_col = p.peek().col
2190
+ strsize = self._parse_expr(p, line, file)
2191
+ if p.is_op("!") and not self.profile.single_pass and errors is not None:
2192
+ # Sized String parameters read through `!` to the next comma or `)`
2193
+ # (oracle, CR6/CR1000X, r67_string_size_junk, cases 087;
2194
+ # docs/checker.md#round-67-review-fixes). Other junk is unobserved.
2195
+ while not p.at_end() and not p.is_op(",") and not p.is_op(")"):
2196
+ p.next()
2197
+ echo = p.seg_text[size_col : p.peek().col].rstrip()
2198
+ errors.append(f"Incomplete expression: {echo}.")
2199
+ default = None
2200
+ if p.is_op("="):
2201
+ if name_tok.kind == "STRING" and not optional:
2202
+ # Required quoted parameters cannot be initialized. Keep the existing
2203
+ # rejection until the profile-specific header diagnostic is modelled.
2204
+ raise _ParseError("required quoted parameter cannot be initialized")
2205
+ p.next()
2206
+ default = self._parse_expr(p, line, file)
2207
+ if errors is not None and not optional and name_tok.kind == "IDENT":
2208
+ # CR300/CR350 print the name as `p` (oracle, round 59)
2209
+ name = "p" if self.profile.single_pass else name_tok.text
2210
+ errors.append(f"Required parameter {name} cannot be initialized.")
2211
+ return S.Param(
2212
+ name=name_tok.text,
2213
+ by_ref=None,
2214
+ type=vtype,
2215
+ string_size=strsize,
2216
+ dims=dims,
2217
+ default=default,
2218
+ dim_exprs=dim_exprs,
2219
+ )
2220
+
2221
+ # ---- Const ------------------------------------------------------
2222
+
2223
+ def _parse_const(self, p: _TokStream, line: int, file: str) -> S.Stmt:
2224
+ p.next()
2225
+ eq_at = None
2226
+ lead = False
2227
+ if p.is_op("="):
2228
+ # `Const = X ...`: the leading `=` is skipped (oracle, 2026-10-10, `docs/checker.md`
2229
+ # "Round 9"); a first item that cannot be a name is its own message
2230
+ j = p.i
2231
+ while p.is_op("="):
2232
+ p.next()
2233
+ tok, after = p.peek(), p.peek(1)
2234
+ if tok.kind == "EOF":
2235
+ return S.Unparsed(line, file, text="", reason="Const without '='")
2236
+ if tok.kind == "IDENT" and not (after.kind == "IDENT" and after.upper == "AS"):
2237
+ lead = True
2238
+ elif tok.kind == "OP" and tok.text == ",":
2239
+ p.i = len(p.toks) - 1
2240
+ return S.Unparsed(line, file, text="", reason="Const lead: CONST needs assignment.")
2241
+ elif (
2242
+ tok.kind == "STRING"
2243
+ or (tok.kind == "OP" and tok.text == "*")
2244
+ or (tok.kind == "NUMBER" and not tok.text.isdigit() and "E" not in tok.text.upper().lstrip("&"))
2245
+ or (tok.kind == "OP" and tok.text == "-" and after.kind == "NUMBER")
2246
+ ):
2247
+ if tok.kind == "OP" and tok.text == "-":
2248
+ text = tok.text + after.text
2249
+ rest_at = 2
2250
+ else:
2251
+ text, rest_at = tok.text, 1
2252
+ msgs = [f"Invalid variable or constant name: {text}."]
2253
+ if text[0].isdigit():
2254
+ msgs.append(f"Const name {text} illegal, starts with a number.")
2255
+ elif p.peek(rest_at).kind == "EOF":
2256
+ msgs.append("CONST needs assignment.")
2257
+ p.i = len(p.toks) - 1
2258
+ return S.Unparsed(line, file, text="", reason="Const lead: " + "\n".join(msgs))
2259
+ else:
2260
+ p.i = j
2261
+ try:
2262
+ name_tok = p.expect_ident()
2263
+ vtype = None
2264
+ if not lead and p.is_op("(") and p.peek().col == name_tok.col + len(name_tok.text):
2265
+ # `Const J(3) = 5` declares the scalar `J = 3`: the first subscript is the value, any
2266
+ # further subscripts and everything after `=` are ignored (oracle, 2026-10-16,
2267
+ # docs/checker.md "Round 18")
2268
+ p.next()
2269
+ while p.is_op(","):
2270
+ p.next() # empty leading subscripts are skipped (oracle, 2026-10-17, "Round 19")
2271
+ if p.is_op(")"):
2272
+ p.next() # `Const J() = 7` is `Const J = 7`
2273
+ else:
2274
+ sub_i = p.i
2275
+ dim_start_col = p.peek().col
2276
+ if p.is_op("="):
2277
+ pass # `Const J( = 1` is `Const J = 1` (oracle, "Round 19")
2278
+ else:
2279
+ try:
2280
+ arr_dim = self._parse_expr(p, line, file)
2281
+ except _ParseError:
2282
+ return self._const_bad_subscript(p, sub_i, line, file)
2283
+ if not (p.at_end() or p.is_op(",") or p.is_op(")")) and any(
2284
+ t.kind == "OP" and t.text == "=" for t in p.toks[sub_i:]
2285
+ ):
2286
+ # `Const J(3 4) = 7`: adjacent operands (oracle, 2026-10-18, "Round 20")
2287
+ return self._const_sub_unparsed(p, sub_i, line, file, CONST_INCOMPLETE)
2288
+ arr_span = (dim_start_col, p.peek().col)
2289
+ # an `=` inside an unclosed subscript counts (`Const J(3 = 5`: J is 0)
2290
+ if any(t.kind == "OP" and t.text == "=" for t in p.toks[sub_i:]):
2291
+ return self._const_array(p, line, file, name_tok, arr_dim, arr_span)
2292
+ raise _ParseError("Const subscript without '='")
2293
+ if p.is_ident("AS"):
2294
+ p.next()
2295
+ type_tok = p.expect_ident()
2296
+ vtype = type_tok.upper
2297
+ word_match = re.match(r"[^\s,]+", p.seg_text[type_tok.col :])
2298
+ assert word_match is not None
2299
+ word = word_match.group()
2300
+ if word.upper() not in _CONST_TYPE_WORDS and any(t.kind == "OP" and t.text == "=" for t in p.toks):
2301
+ # the type is the whitespace-delimited word (`As Long(5)`, `As Long=7`,
2302
+ # `As Long!`): an unknown one makes `J` 0 and ignores the rest of the line
2303
+ # (oracle, 2026-10-17, docs/checker.md "Round 19")
2304
+ p.i = len(p.toks) - 1
2305
+ zero = S.Number(line, file, value=0, text="0")
2306
+ self.env.define(name_tok.text, 0, False)
2307
+ return S.ConstDecl(line, file, name=name_tok.text, value=zero, type=word, value_span=(0, 0))
2308
+ if p.is_op("!"):
2309
+ p.next() # pointer type (see docs/frontend.md)
2310
+ assert vtype is not None
2311
+ vtype += "!"
2312
+ if p.is_op("*") and any(t.kind == "OP" and t.text == "=" for t in p.remaining()):
2313
+ # `Const J As String * 10 = "a"`: the `*` is out of place; the echo runs to the
2314
+ # next top-level `,` (oracle, 2026-10-18, docs/checker.md "Round 20")
2315
+ star = p.peek()
2316
+ depth, end = 0, len(p.toks) - 1
2317
+ for j in range(p.i, len(p.toks) - 1):
2318
+ t = p.toks[j]
2319
+ depth += (t.kind == "OP") * ((t.text == "(") - (t.text == ")"))
2320
+ if t.kind == "OP" and t.text == "," and depth == 0:
2321
+ end = j
2322
+ break
2323
+ stop = p.toks[end].col if p.toks[end].kind != "EOF" else len(p.seg_text)
2324
+ p.i = len(p.toks) - 1
2325
+ return S.Unparsed(line, file, text="", reason="Const bad value: " + p.seg_text[star.col : stop])
2326
+ if vtype == "STRING" and p.is_op("*"):
2327
+ p.next()
2328
+ self._parse_expr(p, line, file)
2329
+ if lead and p.at_end():
2330
+ return S.Unparsed(line, file, text="", reason="Const lead: name " + name_tok.text)
2331
+ if lead and p.is_op(","):
2332
+ p.next() # (`Const = X, Y`: `Y` is the value)
2333
+ elif lead and not p.is_op("="):
2334
+ pass # (`Const = X Y`: `Y` is the value)
2335
+ elif p.is_op("(") and any(t.kind == "OP" and t.text == "=" for t in p.remaining()):
2336
+ pass # `Const J (5) = 7` is `Const J = (5) = 7`: the `=` is implied (oracle, "Round 19")
2337
+ elif (
2338
+ not lead
2339
+ and p.peek().kind == "IDENT"
2340
+ and not p.is_ident("AS")
2341
+ and any(t.kind == "OP" and t.text == "=" for t in p.remaining())
2342
+ ):
2343
+ # `Const Max Length1 = 22`: everything after the name is the value (oracle, all
2344
+ # four loggers, round 41: `Invalid symbol in expression: Length1 = 22.`)
2345
+ rest_col = p.peek().col
2346
+ p.i = len(p.toks) - 1
2347
+ return S.Unparsed(line, file, text="", reason="Const bad value: " + p.seg_text[rest_col:])
2348
+ else:
2349
+ p.expect_op("=")
2350
+ eq_at = p.i
2351
+ value_start_col = p.peek().col
2352
+ outer, self._to_ok = self._to_ok, True # `Const K = 5 To 2` is 0 (oracle, round 9)
2353
+ try:
2354
+ value = self._parse_expr(p, line, file)
2355
+ finally:
2356
+ self._to_ok = outer
2357
+ value_span = (value_start_col, p.peek().col)
2358
+ if p.is_op(",") or p.is_op(")") or p.is_op("}"):
2359
+ # a top-level comma ends the statement, the rest is ignored; so does an unmatched `)`
2360
+ # (`Const Pi = 1) + 2` compiles on all four loggers, oracle 2026-10-12)
2361
+ p.i = len(p.toks) - 1
2362
+ elif not p.at_end():
2363
+ # a value followed by another operand: `Const J = a b`, `(5 4` (oracle, 2026-10-18,
2364
+ # docs/checker.md "Round 20")
2365
+ rest, d = [], 0
2366
+ for t in p.toks[eq_at:-1]:
2367
+ if t.kind == "OP":
2368
+ if t.text == "," and d <= 0:
2369
+ break # the value ends at a top-level comma (`Const X = 1 2, Y = 3`)
2370
+ d += (t.text == "(") - (t.text == ")")
2371
+ rest.append(t)
2372
+ depth = d
2373
+ stop = p.toks[eq_at + len(rest)].col if eq_at + len(rest) < len(p.toks) - 1 else len(p.seg_text)
2374
+ text = p.seg_text[value_start_col:stop]
2375
+ p.i = len(p.toks) - 1
2376
+ return S.Unparsed(
2377
+ line,
2378
+ file,
2379
+ text="",
2380
+ reason="Const bad value: " + (CONST_NOMATCH if depth > 0 else CONST_INCOMPLETE) + text,
2381
+ )
2382
+ except _ParseError:
2383
+ # `Const` with no complete `Name [As Type] = value` on its own physical line -- a CHM
2384
+ # corpus example wrapped the assignment onto the next line with no `_` continuation
2385
+ # (`rectpolar_1.CR6`: `Const` entirely alone, then `Pi=4*ATN(1)`; `sprintf_4.CR6`:
2386
+ # `Const ToAddr As` / `String = "..."`, truncated further into the grammar). The real
2387
+ # compiler reports the same message either way (oracle-confirmed, `docs/checker.md`),
2388
+ # so this doesn't try to distinguish how far through its own grammar it got.
2389
+ if eq_at is not None:
2390
+ # `=` seen, value empty or ending in a binary operator: `Invalid, or out of place
2391
+ # expression: <value>.` (oracle, 2026-10-10, docs/checker.md "Round 7")
2392
+ rest = p.toks[eq_at:]
2393
+ if rest[0].kind == "EOF" or (
2394
+ rest[-2].kind == "OP" and rest[-2].text in "+-*/^=" and len(rest[-2].text) == 1
2395
+ ):
2396
+ value = p.seg_text[rest[0].col :] if rest[0].kind != "EOF" else ""
2397
+ return S.Unparsed(line, file, text="", reason="Const bad value: " + value)
2398
+ depth = sum((t.text == "(") - (t.text == ")") for t in rest if t.kind == "OP")
2399
+ if depth >= 0:
2400
+ # `Const J = (5`, `((5)`, `(5 = 7`: no matching `)`; `(+)`, `* 5`, `5 * * 3`: out of
2401
+ # place (oracle, 2026-10-18, docs/checker.md "Round 20")
2402
+ value = p.seg_text[rest[0].col :]
2403
+ p.i = len(p.toks) - 1
2404
+ return S.Unparsed(
2405
+ line, file, text="", reason="Const bad value: " + (CONST_NOMATCH if depth > 0 else "") + value
2406
+ )
2407
+ body = re.sub(r"(?i)^\s*Const\b", "", p.seg_text, count=1)
2408
+ bad = re.match(r"\s*(?:=\s*)*(\d\w*)(?![\w.])", body)
2409
+ if bad and "=" in body:
2410
+ p.i = len(p.toks) - 1 # (the rest of the statement is consumed: the reason stands)
2411
+ return S.Unparsed(line, file, text="", reason="Const bad name: " + bad.group(1))
2412
+ if "=" not in body or body.strip() == "=":
2413
+ p.i = len(p.toks) - 1
2414
+ return S.Unparsed(line, file, text="", reason="Const without '='")
2415
+ node = S.ConstDecl(line, file, name=name_tok.text, value=value, type=vtype, value_span=value_span)
2416
+ try:
2417
+ self.env.define(name_tok.text, consteval.value_of(value, self.env), consteval.is_float(value, self.env))
2418
+ except consteval.NotConstant:
2419
+ pass
2420
+ return node
2421
+
2422
+ def _const_unterminated(self, seg, q, line, file):
2423
+ """A `Const` line with an unterminated string (oracle, all four loggers, 2026-10-17,
2424
+ docs/checker.md "Round 19"): no `=` at all is the ordinary `Const X` no-assignment; a
2425
+ closed non-empty first subscript before the quote ignores the rest (`J(3) = "abc`); an empty
2426
+ `()`/`(,)` before `=` is `String without end quote` plus the quote's echo; a top-level
2427
+ comma after `=` ends the statement before the quote; anything else is `CONST needs
2428
+ assignment.`"""
2429
+ pre = seg[:q]
2430
+ if "=" not in seg:
2431
+ if re.fullmatch(r"\s*Const\s+[A-Za-z_]\w*(\([^()]*\))?\s+", pre, re.I):
2432
+ return self._parse_text_segment(pre.rstrip(), line, file)
2433
+ return None
2434
+ m = re.match(r"\s*Const\s+([A-Za-z_]\w*)\(\s*([^\s,()]+(?:\s+[^\s,()]+)*)\s*[,)]", pre, re.I)
2435
+ if m:
2436
+ return self._parse_text_segment(f"Const {m[1]}({m[2]}) = 0", line, file)
2437
+ if re.match(r"\s*Const\s+[A-Za-z_]\w*\(\s*,*\s*\)\s*=", pre, re.I):
2438
+ return S.Unparsed(
2439
+ line,
2440
+ file,
2441
+ text="",
2442
+ reason="Const lead: String without end quote\nInvalid, or out of place expression: " + seg[q:] + ".",
2443
+ )
2444
+ eq = pre.find("=")
2445
+ if eq >= 0 and "(" not in pre and "," in pre[eq:]:
2446
+ return self._parse_text_segment(seg[: eq + pre[eq:].index(",")], line, file)
2447
+ return S.Unparsed(line, file, text="", reason="Const lead: CONST needs assignment.")
2448
+
2449
+ def _const_bad_subscript(self, p, start_i, line, file):
2450
+ """`Const J(1+) = 1`: a first subscript ending in an operator is `Invalid, or out of place
2451
+ expression: 1+.` (echo: the subscript's own text up to its `,`/`)`); `J` stays undefined
2452
+ (oracle, 2026-10-17, "Round 19"). Any other bad subscript keeps the generic fallback."""
2453
+ return self._const_sub_unparsed(p, start_i, line, file, "", True)
2454
+
2455
+ def _const_sub_unparsed(self, p, start_i, line, file, kind, need_op=False):
2456
+ depth, end = 0, len(p.toks) - 1
2457
+ for j in range(start_i, len(p.toks) - 1):
2458
+ t = p.toks[j]
2459
+ if t.kind == "OP" and t.text == "(":
2460
+ depth += 1
2461
+ elif t.kind == "OP" and t.text in ",)" and depth == 0:
2462
+ end = j
2463
+ break
2464
+ elif t.kind == "OP" and t.text == ")":
2465
+ depth -= 1
2466
+ last = p.toks[end - 1] if end > start_i else None
2467
+ if not need_op or (
2468
+ last is not None
2469
+ and last.kind == "OP"
2470
+ and last.text in ("+", "-", "*", "/", "^")
2471
+ and any(t.kind == "OP" and t.text == "=" for t in p.toks[end:])
2472
+ ):
2473
+ stop = p.toks[end].col if p.toks[end].kind != "EOF" else len(p.seg_text)
2474
+ text = p.seg_text[p.toks[start_i].col : stop]
2475
+ p.i = len(p.toks) - 1
2476
+ return S.Unparsed(line, file, text="", reason="Const bad value: " + kind + text)
2477
+ raise _ParseError("Const subscript")
2478
+
2479
+ def _const_array(self, p, line, file, name_tok, dim, span):
2480
+ p.i = len(p.toks) - 1 # (everything after the first subscript is ignored)
2481
+ node = S.ConstDecl(line, file, name=name_tok.text, value=dim, type=None, value_span=span)
2482
+ try:
2483
+ self.env.define(name_tok.text, consteval.value_of(dim, self.env), consteval.is_float(dim, self.env))
2484
+ except consteval.NotConstant:
2485
+ pass
2486
+ return node
2487
+
2488
+ # ---- Public / Dim -------------------------------------------------
2489
+
2490
+ def _parse_declaration(self, p: _TokStream, line: int, file: str, kind: str) -> S.Stmt:
2491
+ p.next()
2492
+ if kind == "DIM" and p.is_ident("PUBLIC"):
2493
+ p.next()
2494
+ kind = "PUBLIC"
2495
+ if p.at_end():
2496
+ # `Public`/`Dim` with nothing at all following on its own physical line -- a CHM
2497
+ # corpus example wrapped the name list onto the next line with no `_` continuation
2498
+ # (`log10_1.CR6`: `Public` / `Number, B, Log10ofNumber, LogBofNumber`). The real
2499
+ # compiler's declaration parser reports `Variable not named.` right here rather than
2500
+ # raising a generic parse error (oracle-confirmed, `docs/checker.md`); modelled as a
2501
+ # `Declaration` with an empty `vars` list so `structure.py` can emit that specific
2502
+ # message instead of the generic "not a valid statement" fallback an `Unparsed` would
2503
+ # get.
2504
+ return S.Declaration(line, file, kind=kind, vars=[])
2505
+ vars_: list[S.VarDecl] = []
2506
+ while True:
2507
+ if kind in ("PUBLIC", "DIM") and (bad := self._numeric_name(p)):
2508
+ # (oracle, all four loggers, round 74: `Public 1bad`, `Dim X, 1bad(3) As Long`)
2509
+ self.diags.error(line, f"Variable name {bad} illegal, starts with a number.", file)
2510
+ else:
2511
+ vars_.append(self._parse_vardecl(p, line, file))
2512
+ if not p.is_op(","):
2513
+ break
2514
+ p.next()
2515
+ if p.at_end():
2516
+ # A trailing comma that runs off the end of the line: confirmed valid CRBasic
2517
+ # (stddevrun_3.CR6, `Public Counter As Long,` with nothing further) -- tolerated
2518
+ # the same way as an unclosed paren (`expect_close`). See docs/frontend.md.
2519
+ break
2520
+ if p.is_op(")") or p.is_op("}"):
2521
+ p.i = len(p.toks) - 1 # an unmatched `)`/`}` ends the declaration (oracle, round 14)
2522
+ if not vars_: # every name started with a digit: those errors are all (round 74)
2523
+ return S.Unparsed(line, file, text=p.seg_text or "", reason="empty statement")
2524
+ return S.Declaration(line, file, kind=kind, vars=vars_)
2525
+
2526
+ @staticmethod
2527
+ def _numeric_name(p: _TokStream) -> str | None:
2528
+ """A declared name that starts with a digit (`1bad`), consumed with the rest of its item up
2529
+ to a comma at depth 0; None (nothing consumed) for any other item."""
2530
+ if p.peek().kind != "NUMBER":
2531
+ return None
2532
+ m = re.match(r"\d\w*", p.seg_text[p.peek().col :] if p.seg_text else p.peek().text)
2533
+ depth = 0
2534
+ while not p.at_end() and not (depth == 0 and (p.is_op(",") or p.is_op(")"))):
2535
+ depth += p.is_op("(") - p.is_op(")")
2536
+ p.next()
2537
+ return m.group(0) if m else p.peek().text
2538
+
2539
+ def _close_dims_or_raise(self, p: _TokStream, name_tok: Token) -> None:
2540
+ """A `Public`/`Dim`/`Static` dimension list's own closing `)`, unlike an instruction's
2541
+ argument list, is **not** tolerant of running off the end of the physical line: oracle-
2542
+ confirmed (`docs/checker.md` "Need closing ) shapes") that `Public x(5` alone -- or
2543
+ `Public x(5,3`, or with `EndProg`/more statements on later lines, no different -- always
2544
+ fails with `%s is missing the ) character.`, `%s` = the variable's own name plus
2545
+ everything from its own `(` through the end of *this* physical line, verbatim (identical
2546
+ on all four loggers). Only this exact run-off-the-end-of-line shape is handled here,
2547
+ mirroring `expect_close`'s own EOL condition exactly (`Token.col` indexes straight into
2548
+ `p.seg_text`, oracle-confirmed column-exact); any other malformed token in a dimension
2549
+ list (a real, non-EOL mismatch) still falls through to `expect_close`'s ordinary parse
2550
+ error, unchanged -- not attempted, no oracle evidence gathered for that shape's own
2551
+ message text.
2552
+ """
2553
+ if p.at_end():
2554
+ text = p.seg_text[name_tok.col :] if p.seg_text else name_tok.text
2555
+ raise _ParseError(f"{UNCLOSED_DIM_PREFIX}{text}")
2556
+ p.expect_close(")")
2557
+
2558
+ def _parse_dim(self, p: _TokStream, line: int, file: str) -> S.Expr:
2559
+ """One dimension; operands side by side (`1 8`) become a `Juxtaposed`, which pass 1
2560
+ reports as `Incomplete expression` (docs/checker.md#juxtaposed-dimension-operands-round-54)."""
2561
+ expr = self._parse_expr(p, line, file)
2562
+ if not (p.peek().kind in ("NUMBER", "STRING", "IDENT") or p.is_op("(")):
2563
+ return expr
2564
+ items: list[S.Expr | None] = [expr]
2565
+ while p.peek().kind in ("NUMBER", "STRING", "IDENT") or p.is_op("("):
2566
+ items.append(self._parse_expr(p, line, file))
2567
+ return S.Juxtaposed(line, file, items=items)
2568
+
2569
+ def _parse_vardecl(self, p: _TokStream, line: int, file: str) -> S.VarDecl:
2570
+ name_tok = p.expect_ident()
2571
+ dims: list[S.Expr] = []
2572
+ dim0_span: tuple[int, int] | None = None
2573
+ paren_dim = False
2574
+ if not p.is_op("(") and not p.is_ident("AS"):
2575
+ # `Public A B(10)`, `A B.C(1)`, `A B C(1)`: the words before the parenthesis are
2576
+ # skipped, the parenthesis is the dimension (oracle, 2026-10-10)
2577
+ j = 0
2578
+ while p.peek(j).kind in ("IDENT", "NUMBER", "STRING") or (
2579
+ j and p.peek(j).kind == "OP" and p.peek(j).text == "."
2580
+ ):
2581
+ j += 1
2582
+ if (
2583
+ j
2584
+ and p.peek(j).kind == "OP"
2585
+ and p.peek(j).text == "("
2586
+ and not any(p.peek(k).kind == "IDENT" and p.peek(k).upper == "AS" for k in range(j))
2587
+ ):
2588
+ p.i += j
2589
+ if p.is_op("("):
2590
+ p.next()
2591
+ first = p.i
2592
+ if not p.is_op(")"):
2593
+ start_col = p.peek().col
2594
+ dims.append(self._parse_dim(p, line, file))
2595
+ dim0_span = (start_col, p.peek().col)
2596
+ while p.is_op(","):
2597
+ p.next()
2598
+ dims.append(self._parse_dim(p, line, file))
2599
+ paren_dim = any(t.kind == "OP" and t.text == "(" for t in p.toks[first : p.i])
2600
+ self._close_dims_or_raise(p, name_tok)
2601
+ if paren_dim and len(dims) > 1:
2602
+ keep = _dims_after_type_scan(p.seg_text, p.toks[first - 1].col, p.toks[p.i - 1].col)
2603
+ if keep is not None:
2604
+ dims = dims[:keep]
2605
+ vtype = None
2606
+ strsize = None
2607
+ empty_type_dims = empty_type_init = False
2608
+ init: list[S.Expr | None] | None = None
2609
+ size_missing = None
2610
+ size_junk: tuple[int, str] | None = None
2611
+ if p.is_ident("AS"):
2612
+ p.next()
2613
+ type_tok = p.expect_ident()
2614
+ vtype = type_tok.upper
2615
+ if p.is_op("!"):
2616
+ p.next() # pointer type (see docs/frontend.md)
2617
+ assert vtype is not None
2618
+ vtype += "!"
2619
+ if vtype == "STRING" and p.is_op("*"):
2620
+ star = p.next()
2621
+ if p.at_end() or p.is_op(","):
2622
+ size_missing = star.col + 1 # `String *` with no size (oracle, 2026-10-10)
2623
+ elif not dims and p.peek().kind == "NUMBER" and (p.peek(1).kind == "OP" and p.peek(1).text == "("):
2624
+ # `Public A As String * 16(5) = {...}`: the dimension follows the size
2625
+ # (oracle, all four loggers, round 16; docs/frontend.md)
2626
+ num = p.next()
2627
+ strsize = S.Number(num.line, file, cast(int | float, num.value), num.text)
2628
+ p.next()
2629
+ start_col = p.peek().col
2630
+ dims.append(self._parse_dim(p, line, file))
2631
+ dim0_span = (start_col, p.peek().col)
2632
+ while p.is_op(","):
2633
+ p.next()
2634
+ dims.append(self._parse_dim(p, line, file))
2635
+ self._close_dims_or_raise(p, name_tok)
2636
+ else:
2637
+ size_col = p.peek().col
2638
+ strsize = self._parse_expr(p, line, file)
2639
+ if p.is_op("!") or p.peek().kind == "IDENT":
2640
+ # `As String * 16!`, `As String * 16 X`: reported by `structure.py` (a
2641
+ # number there is already `Incomplete expression`, round 7)
2642
+ size_junk = (size_col, "bang" if p.is_op("!") else "word")
2643
+ elif not dims and p.is_op("("):
2644
+ # `Public floatArr As Float(5) = {...}`: the dimension can follow the type
2645
+ # instead of the variable name. Confirmed valid CRBasic by corpus testing
2646
+ # (pointervariable_1.CR6); see docs/frontend.md.
2647
+ p.next()
2648
+ empty_type_dims = p.is_op(")")
2649
+ if not p.is_op(")"):
2650
+ start_col = p.peek().col
2651
+ dims.append(self._parse_dim(p, line, file))
2652
+ dim0_span = (start_col, p.peek().col)
2653
+ while p.is_op(","):
2654
+ p.next()
2655
+ dims.append(self._parse_dim(p, line, file))
2656
+ self._close_dims_or_raise(p, name_tok)
2657
+ if p.is_op("="):
2658
+ empty_type_init = empty_type_dims
2659
+ p.next()
2660
+ val = self._parse_expr(p, line, file)
2661
+ init = list(val.items) if isinstance(val, S.ArrayLiteral) else [val]
2662
+ # Oracle-confirmed quirk (`docs/frontend.md`): once a variable's own `As Type` clause
2663
+ # has been fully parsed, any further tokens before the next comma (or end of line) are
2664
+ # silently discarded rather than rejected -- `Public X As Long asX String, Y As Float`
2665
+ # compiles, `Y` declared normally, `asX String` simply vanishes (confirmed not
2666
+ # resolvable afterwards, not a second variable).
2667
+ while not p.at_end() and not p.is_op(","):
2668
+ p.next()
2669
+ elif p.is_op("="):
2670
+ p.next()
2671
+ val = self._parse_expr(p, line, file)
2672
+ init = list(val.items) if isinstance(val, S.ArrayLiteral) else [val]
2673
+ elif dims and not (p.at_end() or p.is_op(",")):
2674
+ # Oracle-confirmed round-5 exception to the malformed-`As`-clause rule just below
2675
+ # (`docs/checker.md` "Round 5", a fuzz-found false reject): an **array** declaration
2676
+ # (`dims` non-empty) never raises here at all, regardless of what token follows the
2677
+ # dimensions -- `Public Result(4) as_Fz Boolean` compiles clean (`Result` left at its
2678
+ # default type, the same as `Public Result(4)` alone), where the exact same malformed
2679
+ # token after a *scalar* declaration (`Public Result as_Fz Boolean`, no dims) correctly
2680
+ # raises `Invalid Variable declaration as_Fz.` (oracle-confirmed side by side). A later
2681
+ # variable in the same comma list still parses normally (`Public Result(4) as_Fz
2682
+ # Boolean, Y As Long` declares `Y` fine), matching the ordinary "discard anything before
2683
+ # the next comma" quirk just above -- so this is modelled the same way, silently
2684
+ # skipping to the next comma/end of line rather than raising. A comma inside braces
2685
+ # or parentheses does not end it: `Public X(3) Long = {3,1,4}, Y` compiles and declares
2686
+ # `Y` (oracle, CR6/CR1000X, round 59).
2687
+ depth = 0
2688
+ while not p.at_end() and not (depth == 0 and p.is_op(",")):
2689
+ tok = p.next()
2690
+ if tok.kind == "OP" and tok.text in ("(", "{"):
2691
+ depth += 1
2692
+ elif tok.kind == "OP" and tok.text in (")", "}"):
2693
+ depth = max(0, depth - 1)
2694
+ elif not (p.at_end() or p.is_op(",")):
2695
+ # Oracle-confirmed (`docs/fuzz-findings.md` "5. Message diff", well evidenced by 6
2696
+ # independent fuzz mutants): a token here -- right after the variable's own name/dims,
2697
+ # where `As`, `=`, a comma or end of line was expected -- that is none of those is a
2698
+ # malformed `As` clause, most often a corrupted spelling of the keyword itself (`asX`,
2699
+ # `As_Mut`, a stray extra space splitting one identifier into two, ...). The real
2700
+ # compiler reports it immediately and abandons the *whole* declaration statement, not
2701
+ # just this one variable: no later variable in the same comma list is parsed or
2702
+ # reported, confirmed with a valid first variable followed by a malformed second one.
2703
+ bad = p.peek()
2704
+ raise _ParseError(f"Invalid As clause: {bad.text}")
2705
+ junk_dims: list[S.Expr] = []
2706
+ if any(isinstance(d, S.Juxtaposed) for d in dims):
2707
+ # the compiler keeps the dimension as size 1 (round 54, alias bounds)
2708
+ junk_dims, dims = dims, [S.Number(d.line, file, 1, "1") if isinstance(d, S.Juxtaposed) else d for d in dims]
2709
+ decl = S.VarDecl(
2710
+ name=name_tok.text,
2711
+ dims=dims,
2712
+ type=vtype,
2713
+ string_size=strsize,
2714
+ init=init,
2715
+ dim0_span=dim0_span,
2716
+ paren_dim=paren_dim,
2717
+ junk_dims=junk_dims,
2718
+ )
2719
+ decl.size_missing = size_missing # column just after the `*`, or None
2720
+ decl.size_junk = size_junk # (column of the String size, kind of what follows it), or None
2721
+ decl.empty_type_init = empty_type_init # `As Float() = ...`, reported by structure.py
2722
+ return decl
2723
+
2724
+ # ---- Alias / Units --------------------------------------------------
2725
+
2726
+ def _parse_alias(self, p: _TokStream, line: int, file: str) -> S.Stmt:
2727
+ p.next()
2728
+ try:
2729
+ lhs = self._parse_atom(p, line, file) # lvalue only; see `_parse_assignment_or_call`
2730
+ except _ParseError:
2731
+ # `Alias` with no `Target = Name` on its own physical line -- the same CHM line-wrap
2732
+ # shape as `Const`/`Units`/`Public` above (`rectpolar_1.CR6`: `Alias` entirely alone,
2733
+ # then `XY(1)=X`). Oracle-confirmed message: `docs/checker.md`.
2734
+ return S.Unparsed(line, file, text="", reason="Alias without '='")
2735
+ try:
2736
+ p.expect_op("=")
2737
+ except _ParseError:
2738
+ # Target parsed fine (present, valid lvalue), but the `=` itself is what runs off the
2739
+ # end of this physical line (`realtime_1.CR6`: `Alias rTime(1)` / `= Year` on the next
2740
+ # line, no `_` continuation) -- a distinct reason from the fully-empty case above so
2741
+ # `checker/structure.py` can echo the oracle-confirmed embedded-newline shape
2742
+ # (`docs/checker.md` "Echo quirk"): `Need '=' in alias definition: rTime(1)\n.`.
2743
+ while not p.at_end():
2744
+ p.next() # junk after the target belongs to the same message
2745
+ return S.Unparsed(line, file, text="", reason="Alias target with no '='")
2746
+ if p.at_end():
2747
+ return S.Unparsed(line, file, text="", reason="Alias nothing after '='")
2748
+ if p.peek().kind == "IDENT":
2749
+ # Each comma-separated item is one name (optionally indexed); anything after it up to
2750
+ # the next comma is ignored (oracle, all four loggers, 2026-09-27: `= Spare2_1 3`,
2751
+ # `= Sp + 1`, `= Sp As Long` compile; in `= A1, B1` both names are declared, in
2752
+ # `= A1 B1` only `A1`) -- a fuzz-found false reject.
2753
+ rhs = []
2754
+ junk = False
2755
+ item_junk: list[bool] = []
2756
+ while not p.at_end() and p.peek().kind == "IDENT":
2757
+ rhs.append(self._parse_atom(p, line, file))
2758
+ item_junk.append(False)
2759
+ depth = 0
2760
+ while not p.at_end() and not (depth == 0 and p.is_op(",")):
2761
+ junk = junk or len(rhs) == 1
2762
+ item_junk[-1] = True
2763
+ tok = p.next()
2764
+ if tok.kind == "OP" and tok.text == "(":
2765
+ depth += 1
2766
+ elif tok.kind == "OP" and tok.text == ")":
2767
+ depth = max(0, depth - 1)
2768
+ if p.at_end():
2769
+ break
2770
+ p.next() # the comma
2771
+ while not p.at_end():
2772
+ p.next()
2773
+ inst = S.Instruction(line, file, keyword="ALIAS", args=[lhs, *rhs])
2774
+ inst.alias_junk = junk # something follows the first name (`= Sp 3`, `= Sp As Long`)
2775
+ inst.alias_item_junk = item_junk # per name; the dimension checks skip such items
2776
+ return inst
2777
+ rhs = [self._parse_expr(p, line, file)]
2778
+ while p.is_op(","):
2779
+ p.next()
2780
+ rhs.append(self._parse_expr(p, line, file))
2781
+ return S.Instruction(line, file, keyword="ALIAS", args=[lhs, *rhs])
2782
+
2783
+ def _parse_units_segment(self, seg_text: str, line: int, file: str) -> S.Stmt:
2784
+ """`Units` is handled before general tokenisation of its segment: `UnitLabel` is
2785
+ free-form text, not an expression (`Units Acclima_BulkEC()=uS/cm`, `Units
2786
+ Acclima_VWC()=%` -- `%` is not even a valid CRBasic token, and some real labels carry a
2787
+ raw ISO-8859-1 byte, e.g. a degree sign), so the label is taken verbatim from the source
2788
+ text rather than lexed (found by corpus testing; see docs/frontend.md).
2789
+ """
2790
+ eq = seg_text.find("=")
2791
+ if eq == -1:
2792
+ # No `=` anywhere on this physical line. If there's a target after the keyword and it
2793
+ # parses as a valid lvalue by itself (`Units x` alone, the next line starting a new
2794
+ # statement with no `_` continuation), the real compiler's echo embeds this line's own
2795
+ # trailing newline before the closing `.` (oracle probe, `docs/checker.md` "Echo
2796
+ # quirk"): `Need '=' in units definition: x\n.`. A fully empty target (`Units` alone)
2797
+ # or one that fails to parse gets the plain, un-suffixed reason instead -- no oracle
2798
+ # evidence for the latter shape.
2799
+ target_text = seg_text.strip()[len("UNITS") :].strip()
2800
+ if target_text:
2801
+ try:
2802
+ target_tokens = lexer.tokenize(target_text, line)
2803
+ tp = _TokStream(target_tokens)
2804
+ self._parse_atom(tp, line, file)
2805
+ if tp.at_end():
2806
+ return S.Unparsed(line, file, text=target_text, reason="Units target with no '='")
2807
+ except lexer.LexError, _ParseError:
2808
+ pass
2809
+ return S.Unparsed(line, file, text=seg_text.strip(), reason="Units without '='")
2810
+ lhs_text, label = seg_text[:eq], seg_text[eq + 1 :].strip()
2811
+ try:
2812
+ lhs_tokens = lexer.tokenize(lhs_text, line)
2813
+ except lexer.LexError as e:
2814
+ return S.Unparsed(line, file, text=seg_text.strip(), reason=f"lex error in Units target: {e.message}")
2815
+ p = _TokStream(lhs_tokens)
2816
+ try:
2817
+ p.expect_ident("UNITS")
2818
+ lhs = self._parse_atom(p, line, file)
2819
+ # anything between the first target and the `=` is ignored (`Units PP S = ms`,
2820
+ # `Units A + 1 = ms`; oracle, all four loggers, round 16). A `,` list checks every
2821
+ # name and a non-name first target has its own error, so those stay unparsed.
2822
+ extra_targets: list[S.Expr] = []
2823
+ if not p.at_end() and not isinstance(lhs, (S.Name, S.Index)):
2824
+ raise _ParseError("unexpected trailing input in Units target")
2825
+ while p.is_op(","): # `Units A, B = ms`: every name is checked
2826
+ p.next()
2827
+ if p.at_end():
2828
+ break
2829
+ extra = self._parse_atom(p, line, file)
2830
+ if not isinstance(extra, (S.Name, S.Index)):
2831
+ raise _ParseError("unexpected trailing input in Units target")
2832
+ extra_targets.append(extra)
2833
+ except _ParseError as e:
2834
+ return S.Unparsed(line, file, text=seg_text.strip(), reason=str(e))
2835
+ stmt = S.Instruction(line, file, keyword="UNITS", args=[lhs, S.String(line, file, label)])
2836
+ stmt.extra_targets = extra_targets
2837
+ stmt.text = seg_text.strip()
2838
+ return stmt
2839
+
2840
+ # ------------------------------------------------------- expressions
2841
+
2842
+ _LOGICAL_OPS = {"IMP", "EQV", "OR", "XOR", "AND"}
2843
+
2844
+ def _parse_expr(self, p: _TokStream, line: int, file: str) -> S.Expr:
2845
+ return self._parse_logical(p, line, file)
2846
+
2847
+ def _parse_logical(self, p: _TokStream, line: int, file: str) -> S.Expr:
2848
+ left = self._parse_not(p, line, file)
2849
+ while p.peek().kind == "IDENT" and p.peek().upper in self._LOGICAL_OPS:
2850
+ op = p.next().upper
2851
+ if self._logical_assign and self._at_assign_target(p):
2852
+ # `=` binds below the logical words here: `A AND B = C AND D` is `A AND (B = (C AND
2853
+ # D))` (oracle, all four loggers, 2026-10-10, docs/checker.md "Logical-prefixed
2854
+ # assignment targets")
2855
+ target = self._parse_atom(p, line, file)
2856
+ p.next()
2857
+ right = S.Binary(line, file, "ASSIGN", target, self._parse_logical(p, line, file))
2858
+ else:
2859
+ right = self._parse_not(p, line, file)
2860
+ left = S.Binary(line, file, op, left, right)
2861
+ return left
2862
+
2863
+ @staticmethod
2864
+ def _at_assign_target(p: _TokStream) -> bool:
2865
+ """`name =`, `name(...) =` or `name.member[(...)] =` ahead."""
2866
+ if p.is_op("("):
2867
+ # `(Y) = 1`, `((Y)) = 1`, `(A(1)) = 1`: a parenthesised target (oracle, all four
2868
+ # loggers, 2026-10-12)
2869
+ depth, k = 0, 0
2870
+ while p.peek(k).kind != "EOF":
2871
+ t = p.peek(k)
2872
+ depth += (t.text == "(") - (t.text == ")") if t.kind == "OP" else 0
2873
+ k += 1
2874
+ if depth == 0:
2875
+ break
2876
+ return p.is_op("=", k)
2877
+ if p.peek().kind != "IDENT" or p.peek().upper in _WORD_BINARY_OPS | {"NOT"}:
2878
+ return False
2879
+ k = 1
2880
+ while p.is_op(".", k) and p.peek(k + 1).kind == "IDENT":
2881
+ k += 2
2882
+ if k > 1 and not p.is_op("(", k):
2883
+ return p.is_op("=", k)
2884
+ if p.is_op("(", k):
2885
+ depth = 0
2886
+ while p.peek(k).kind != "EOF":
2887
+ t = p.peek(k)
2888
+ depth += (t.text == "(") - (t.text == ")") if t.kind == "OP" else 0
2889
+ k += 1
2890
+ if depth == 0:
2891
+ break
2892
+ return p.is_op("=", k)
2893
+
2894
+ def _parse_not(self, p: _TokStream, line: int, file: str) -> S.Expr:
2895
+ if p.is_ident("NOT"):
2896
+ tok = p.next()
2897
+ operand = self._parse_not(p, line, file)
2898
+ return S.Unary(tok.line, file, "NOT", operand)
2899
+ return self._parse_relational(p, line, file)
2900
+
2901
+ def _parse_relational(self, p: _TokStream, line: int, file: str) -> S.Expr:
2902
+ left = self._parse_shift(p, line, file)
2903
+ if self._to_ok and p.is_ident("TO"):
2904
+ # `To`: a binary operator between comparison and shift, no comparison on either side
2905
+ # of it, once per operand; a string literal operand is a different error (oracle,
2906
+ # 2026-10-10: `String is illegal here`)
2907
+ p.next()
2908
+ nots = []
2909
+ while p.is_ident("NOT"): # a `Not` operand is not a relational: `1 To Not 2 To 3` is incomplete
2910
+ nots.append(p.next())
2911
+ right = self._parse_shift(p, line, file)
2912
+ for tok in reversed(nots):
2913
+ right = S.Unary(tok.line, file, "NOT", right)
2914
+ return S.Binary(line, file, "TO", left, right)
2915
+ while p.peek().kind == "OP" and p.peek().text in _RELOPS:
2916
+ op = p.next().text
2917
+ right = self._parse_shift(p, line, file)
2918
+ left = S.Binary(line, file, op, left, right)
2919
+ return left
2920
+
2921
+ def _parse_shift(self, p: _TokStream, line: int, file: str) -> S.Expr:
2922
+ left = self._parse_additive(p, line, file)
2923
+ while p.peek().kind == "OP" and p.peek().text in ("<<", ">>"):
2924
+ op = p.next().text
2925
+ right = self._parse_additive(p, line, file)
2926
+ left = S.Binary(line, file, op, left, right)
2927
+ return left
2928
+
2929
+ def _parse_additive(self, p: _TokStream, line: int, file: str) -> S.Expr:
2930
+ left = self._parse_mod(p, line, file)
2931
+ while p.peek().kind == "OP" and p.peek().text in ("&", "+", "-"):
2932
+ op = p.next().text
2933
+ right = self._parse_mod(p, line, file)
2934
+ left = S.Binary(line, file, op, left, right)
2935
+ return left
2936
+
2937
+ def _parse_mod(self, p: _TokStream, line: int, file: str) -> S.Expr:
2938
+ left = self._parse_intdiv(p, line, file)
2939
+ while p.is_ident("MOD"):
2940
+ p.next()
2941
+ right = self._parse_intdiv(p, line, file)
2942
+ left = S.Binary(line, file, "MOD", left, right)
2943
+ return left
2944
+
2945
+ def _parse_intdiv(self, p: _TokStream, line: int, file: str) -> S.Expr:
2946
+ left = self._parse_muldiv(p, line, file)
2947
+ while (p.peek().kind == "OP" and p.peek().text == "\\") or p.is_ident("INTDV"):
2948
+ # `INTDV` is a word spelling of `\` (integer division; docs/binary.md kind 311),
2949
+ # found missing by corpus testing -- see docs/frontend.md.
2950
+ p.next()
2951
+ right = self._parse_muldiv(p, line, file)
2952
+ left = S.Binary(line, file, "\\", left, right)
2953
+ return left
2954
+
2955
+ def _parse_muldiv(self, p: _TokStream, line: int, file: str) -> S.Expr:
2956
+ left = self._parse_unary(p, line, file)
2957
+ while p.peek().kind == "OP" and p.peek().text in ("*", "/"):
2958
+ op = p.next().text
2959
+ right = self._parse_unary(p, line, file)
2960
+ left = S.Binary(line, file, op, left, right)
2961
+ return left
2962
+
2963
+ def _parse_unary(self, p: _TokStream, line: int, file: str) -> S.Expr:
2964
+ if p.peek().kind == "OP" and p.peek().text in ("-", "+"):
2965
+ tok = p.next()
2966
+ operand = self._parse_unary(p, line, file)
2967
+ return S.Unary(tok.line, file, tok.text, operand)
2968
+ if p.is_ident("NOT"): # `1 + Not 2 To 3`, `1 = Not 2`, `-Not 2` compile: Not is an operand prefix
2969
+ tok = p.next()
2970
+ return S.Unary(tok.line, file, "NOT", self._parse_unary(p, line, file))
2971
+ return self._parse_power(p, line, file)
2972
+
2973
+ def _parse_power(self, p: _TokStream, line: int, file: str) -> S.Expr:
2974
+ left = self._parse_atom(p, line, file)
2975
+ while p.peek().kind == "OP" and p.peek().text == "^":
2976
+ p.next()
2977
+ # The right operand must NOT recurse into `_parse_unary` -> `_parse_power`: that
2978
+ # would let it swallow a *chain* of further `^`, making `^` effectively
2979
+ # right-associative. `^` is left-associative (confirmed with the oracle: `2^3^2 =
2980
+ # 64`, not 512 -- see docs/frontend.md), so the right operand is just a single
2981
+ # (optionally signed) atom; the enclosing `while` loop handles the chaining.
2982
+ right = self._parse_power_operand(p, line, file)
2983
+ left = S.Binary(line, file, "^", left, right)
2984
+ return left
2985
+
2986
+ def _parse_power_operand(self, p: _TokStream, line: int, file: str) -> S.Expr:
2987
+ if p.peek().kind == "OP" and p.peek().text in ("-", "+"):
2988
+ tok = p.next()
2989
+ operand = self._parse_power_operand(p, line, file)
2990
+ return S.Unary(tok.line, file, tok.text, operand)
2991
+ if p.is_ident("NOT"): # `2 ^ Not 2 To 3` compiles (oracle)
2992
+ tok = p.next()
2993
+ return S.Unary(tok.line, file, "NOT", self._parse_power_operand(p, line, file))
2994
+ return self._parse_atom(p, line, file)
2995
+
2996
+ def _parse_atom(self, p: _TokStream, line: int, file: str, strict: bool = True) -> S.Expr:
2997
+ # `strict=False` is passed only from `_parse_assignment_or_call`'s own initial lvalue-or-
2998
+ # bare-call atom parse: a bare `Foo(1,2,3` (no `Call` keyword, nothing further on the
2999
+ # line) is oracle-confirmed to compile just like the explicit-`Call`/instruction-keyword
3000
+ # forms (`expect_close`'s own EOL tolerance) -- but *only* for this atom's own outermost
3001
+ # parens; an argument nested inside it (parsed via `_parse_expr`, not this parameter) is
3002
+ # ordinary and strict again, oracle-confirmed (`docs/checker.md` "Need closing ) shapes",
3003
+ # `Foo(Sin(1` alone: the outer call compiles as far as its own `)`, but the unclosed inner
3004
+ # `Sin(1` still fails). Every other caller leaves this at its default.
3005
+ tok = p.peek()
3006
+ if tok.kind == "OP" and tok.text in ("!", "@"):
3007
+ # Pointer variables: `!x` dereferences (also valid as an assignment target -- `!val1
3008
+ # = !val2` -- so this is handled at atom level, not just as a read-only unary op),
3009
+ # `@x` takes the address of `x`. Confirmed valid CRBasic by corpus testing
3010
+ # (pointervariable_1.CR6, pointervariable_3.CR6); see docs/frontend.md.
3011
+ p.next()
3012
+ operand = self._parse_atom(p, line, file)
3013
+ return S.Unary(tok.line, file, tok.text, operand)
3014
+ if tok.kind == "NUMBER":
3015
+ p.next()
3016
+ return S.Number(tok.line, file, cast(int | float, tok.value), tok.text)
3017
+ if tok.kind == "STRING":
3018
+ p.next()
3019
+ return S.String(tok.line, file, cast(str, tok.value))
3020
+ if tok.kind == "OP" and tok.text == "(":
3021
+ p.next()
3022
+ e = self._parse_expr(p, line, file)
3023
+ if strict and p.at_end():
3024
+ # Oracle-confirmed (`docs/checker.md` "Need closing ) shapes"): a bare grouping
3025
+ # paren left open to end-of-line inside a general expression is rejected exactly
3026
+ # like an unclosed call, `No Matching, or out of place ) in expression: %s.`, not
3027
+ # silently completed the way an instruction/`Call` argument list is.
3028
+ raise _ParseError(UNCLOSED_PAREN_EXPR)
3029
+ p.expect_close(")")
3030
+ return e
3031
+ if tok.kind == "OP" and tok.text == "{":
3032
+ p.next()
3033
+ items: list[S.Expr | None] = []
3034
+ if not p.is_op("}"):
3035
+ items.append(self._parse_expr(p, line, file))
3036
+ while p.is_op(","):
3037
+ p.next()
3038
+ items.append(self._parse_expr(p, line, file))
3039
+ p.expect_close("}")
3040
+ return S.ArrayLiteral(tok.line, file, items)
3041
+ if tok.kind == "IDENT":
3042
+ p.next()
3043
+ node: S.Expr = S.Name(tok.line, file, tok.text)
3044
+ if p.is_op("("):
3045
+ p.next()
3046
+ args, _spans = self._parse_arglist(p, ")", line, file, strict=strict)
3047
+ node = S.Index(tok.line, file, tok.text, args)
3048
+ while p.is_op("."):
3049
+ if (
3050
+ isinstance(node, S.Name)
3051
+ and (p.peek(1).kind != "IDENT" or p.peek(1).col > p.peek().col + 1)
3052
+ and (self.profile.known_first_word_pass1 or p.i > 1)
3053
+ ):
3054
+ # a trailing `.` stays on the name (`junk.`: an unknown name, oracle 2026-10-08;
3055
+ # `T.` compiles for a table, `X.` is `Could not find X.`, all four loggers; a blank
3056
+ # between the dot and a name ends the member: `T. To 3`, `T. Then`, `T. X` adjacent operands)
3057
+ p.next()
3058
+ node = S.Name(tok.line, file, tok.text + ".")
3059
+ break
3060
+ p.next()
3061
+ member_tok = p.expect_ident()
3062
+ margs = None
3063
+ if p.is_op("("):
3064
+ p.next()
3065
+ margs, _mspans = self._parse_arglist(p, ")", line, file)
3066
+ node = S.Member(tok.line, file, node, member_tok.text, margs)
3067
+ return node
3068
+ raise _ParseError(f"expected an expression, found {tok.text or tok.kind!r}")
3069
+
3070
+ def _parse_arglist(
3071
+ self, p: _TokStream, close: str, line: int, file: str, strict: bool = True
3072
+ ) -> tuple[list[S.Expr | None], list[tuple[int, int] | None]]:
3073
+ # `strict=False` (only `_parse_generic_instruction`/`_parse_call`'s own direct argument
3074
+ # list, plus the one bare-call-detection atom above): an instruction/`Call` argument list
3075
+ # left open to end-of-line silently completes right there (`expect_close`, pre-existing,
3076
+ # oracle-confirmed). `strict=True` (the default -- every other caller, i.e. this argument
3077
+ # list belongs to a call reached through the general expression grammar): the same shape
3078
+ # instead raises, becoming the real compiler's own `No Matching, or out of place ) in
3079
+ # expression: %s.` (`docs/checker.md` "Need closing ) shapes").
3080
+ args: list[S.Expr | None] = []
3081
+ spans: list[tuple[int, int] | None] = []
3082
+ if p.is_op(close):
3083
+ p.next()
3084
+ return args, spans
3085
+ if not p.at_end():
3086
+ while True:
3087
+ if p.at_end() or (p.peek().kind == "OP" and p.peek().text in (",", close)):
3088
+ args.append(None)
3089
+ spans.append(None)
3090
+ else:
3091
+ start_col = p.peek().col
3092
+ args.append(self._parse_expr(p, line, file))
3093
+ spans.append((start_col, p.peek().col))
3094
+ if p.is_op(","):
3095
+ p.next()
3096
+ continue
3097
+ break
3098
+ if strict and p.at_end():
3099
+ # Ran off the end of the line with the call never closed -- lenient callers keep the
3100
+ # pre-existing "implicit close at end-of-line" behaviour via `expect_close` below;
3101
+ # strict callers (any call reached through the general expression grammar) raise
3102
+ # instead, oracle-confirmed (see `_parse_atom`'s own docstring above).
3103
+ raise _ParseError(UNCLOSED_PAREN_EXPR)
3104
+ p.expect_close(close)
3105
+ return args, spans
3106
+
3107
+ def _parse_bare_arglist(
3108
+ self, p: _TokStream, line: int, file: str
3109
+ ) -> tuple[list[S.Expr | None], list[tuple[int, int] | None]]:
3110
+ start_col = p.peek().col
3111
+ args: list[S.Expr | None] = [self._parse_expr(p, line, file)]
3112
+ spans: list[tuple[int, int] | None] = [(start_col, p.peek().col)]
3113
+ while p.is_op(","):
3114
+ p.next()
3115
+ start_col = p.peek().col
3116
+ args.append(self._parse_expr(p, line, file))
3117
+ spans.append((start_col, p.peek().col))
3118
+ return args, spans
3119
+
3120
+
3121
+ def _profile_for(name: str) -> profiles.Profile:
3122
+ return profiles.for_file(Path(name))
3123
+
3124
+
3125
+ def partial_expression(text: str, profile: profiles.Profile) -> S.Expr | None:
3126
+ """The longest expression an unparsable statement starts with (after a plain `target =`), or
3127
+ None: what the real compiler has parsed, and type-checked, before it meets the junk."""
3128
+ try:
3129
+ toks = [t for t in lexer.tokenize(text, 0) if t.kind != "EOF"]
3130
+ start = 0
3131
+ if len(toks) > 2 and toks[0].kind == "IDENT":
3132
+ if toks[1].text == "=":
3133
+ start = 2
3134
+ elif toks[1].text == "(":
3135
+ depth = 0
3136
+ for j in range(1, len(toks)):
3137
+ depth += (toks[j].text == "(") - (toks[j].text == ")") if toks[j].kind == "OP" else 0
3138
+ if depth == 0:
3139
+ start = j + 2 if j + 1 < len(toks) and toks[j + 1].text == "=" else 0
3140
+ break
3141
+ parser_ = _Parser(profile, Diagnostics())
3142
+ parser_._logical_assign = True
3143
+ body = toks[start:]
3144
+ depth = sum((t.text == "(") - (t.text == ")") for t in body if t.kind == "OP")
3145
+ if body and (
3146
+ (body[-1].kind == "IDENT" and body[-1].upper in _Parser._LOGICAL_OPS)
3147
+ or (body[-1].kind == "OP" and body[-1].text == "=")
3148
+ ):
3149
+ # a logical word (or `=`) with no right operand still has its left operand's strings
3150
+ # counted, as if a number followed (oracle, all four loggers, 2026-10-12)
3151
+ body = body + [Token("NUMBER", "0", "0", 0, 0, 0)]
3152
+ try:
3153
+ return parser_._parse_expr(
3154
+ _TokStream(body + [Token("OP", ")", ")", 0, 0)] * max(depth, 0) + [_eof_like(0)], text), 0, ""
3155
+ )
3156
+ except _ParseError:
3157
+ pass
3158
+ for cut in range(len(toks), start, -1): # a dangling tail (`... AND state =`) is dropped
3159
+ try:
3160
+ return parser_._parse_expr(_TokStream(toks[start:cut] + [_eof_like(0)], text), 0, "")
3161
+ except _ParseError:
3162
+ continue
3163
+ return None
3164
+ except Exception: # a shape the expression grammar cannot take (`{`): nothing was parsed
3165
+ return None
3166
+
3167
+
3168
+ _source_module = source # parse_file's `source` parameter shadows the module
3169
+
3170
+
3171
+ def parse_file(
3172
+ path: Path | str,
3173
+ profile: profiles.Profile | None = None,
3174
+ pull_limit: tuple[int, ...] | None = None,
3175
+ source: SourceProvider | None = None,
3176
+ provenance: bool = True,
3177
+ ) -> tuple[S.Program, Diagnostics]:
3178
+ """`provenance=False` skips the pieces/line-map/origin data only the language server reads (the CLI
3179
+ path); everything the checker sees is unchanged. `profile` is normally left to the file's own extension (`profiles.for_file`, the historical
3180
+ default); the caller passes it explicitly when the logger was chosen another way (`--logger`,
3181
+ `compiler.compile_file`'s own `profile` argument) so `Const` folding during parsing (predefined
3182
+ symbols such as `U1`) uses the *same* profile the checker later runs with. Before this, a
3183
+ `.CR6` file always folded against the CR6 profile regardless of `--logger`, silently baking a
3184
+ CR6 symbol value into a non-CR6 profile's `program.consts` -- `docs/loggers.md`."""
3185
+ path = Path(path)
3186
+ lines, diags = _source_module.load_program(
3187
+ path,
3188
+ _INCLUDE_OWN_EXT.get((profile or _profile_for(path.name)).name),
3189
+ _pulls_init(profile or _profile_for(path.name)),
3190
+ pull_limit,
3191
+ _CR3XX_INCLUDE_EXTS.get((profile or _profile_for(path.name)).name),
3192
+ source,
3193
+ provenance,
3194
+ )
3195
+ pull_log = diags.pull_log
3196
+ parser = _Parser(profile or _profile_for(path.name), diags, provenance)
3197
+ program, diags = parser.run(lines, path.name)
3198
+ program.pull_log = pull_log
3199
+ program.pull_site = diags.pull_site
3200
+ program.include_paths = diags.include_paths
3201
+ program.line_map = parser.line_map
3202
+ program.occurrences = diags.occurrences
3203
+ _include_warning_drives(diags, profile or _profile_for(path.name))
3204
+ main = (source or DiskSource()).read(path)
3205
+ if main is None:
3206
+ raise FileNotFoundError(str(path))
3207
+ _set_last_line(program, parser, main.decode("latin-1"))
3208
+ return program, diags
3209
+
3210
+
3211
+ def _pulls_init(profile: profiles.Profile) -> bool:
3212
+ """All four loggers pull the line after a `Public`/`Dim` initializer's `=`/`,` into the declaration."""
3213
+ return profile.name in ("cr6", "cr1000x", "cr300", "cr350")
3214
+
3215
+
3216
+ _MISSING_INCLUDE_DRIVE_RE = re.compile(r"^(Cannot open include file )(?:CPU|USR|CRD|USB):", re.I)
3217
+
3218
+
3219
+ _INCLUDE_OWN_EXT = {"cr6": "CR6", "cr1000x": "CR1X"}
3220
+ _CR3XX_INCLUDE_EXTS = {"cr300": ("CR300", "CR310"), "cr350": ("CR350",)}
3221
+
3222
+
3223
+ def _include_warning_drives(diags: Diagnostics, profile: profiles.Profile) -> None:
3224
+ """CR6/CR1000X print a missing include without its drive (`Cannot open include file
3225
+ NoSuch.crb.` for `"CPU:NoSuch.crb"`); CR300/CR350 keep it (oracle, 2026-09-28)."""
3226
+ if profile.include_drive_prefix:
3227
+ return
3228
+ diags.items[:] = [
3229
+ dataclasses.replace(d, message=_MISSING_INCLUDE_DRIVE_RE.sub(r"\1", d.message))
3230
+ if d.severity == "warning"
3231
+ else d
3232
+ for d in diags.items
3233
+ ]
3234
+
3235
+
3236
+ def _set_last_line(program: S.Program, parser: _Parser, main_text: str) -> None:
3237
+ """The line end-of-program messages are reported on: `EndProg`'s, or else the main file's
3238
+ last physical line, trailing blank lines included (oracle, CR6: `Public X` plus two blank
3239
+ lines -> `line 3: No BeginProg in Program.`)."""
3240
+ if parser._done and program.statements:
3241
+ program.last_line = program.statements[-1].line
3242
+ else:
3243
+ program.last_line = max(1, len(main_text.splitlines()))
3244
+
3245
+
3246
+ def parse_text(
3247
+ text: str, name: str = "t.cr6", directory: Path | str | None = None, profile: profiles.Profile | None = None
3248
+ ) -> tuple[S.Program, Diagnostics]:
3249
+ directory = Path(directory) if directory is not None else None
3250
+ lines, diags = source.load_text(
3251
+ text,
3252
+ name,
3253
+ directory,
3254
+ _INCLUDE_OWN_EXT.get((profile or _profile_for(name)).name),
3255
+ _pulls_init(profile or _profile_for(name)),
3256
+ _CR3XX_INCLUDE_EXTS.get((profile or _profile_for(name)).name),
3257
+ )
3258
+ parser = _Parser(profile or _profile_for(name), diags)
3259
+ program, diags = parser.run(lines, name)
3260
+ program.line_map = parser.line_map
3261
+ program.occurrences = diags.occurrences
3262
+ _include_warning_drives(diags, profile or _profile_for(name))
3263
+ _set_last_line(program, parser, text)
3264
+ return program, diags