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.
- opencrbasic/__init__.py +3 -0
- opencrbasic/__main__.py +5 -0
- opencrbasic/analysis.py +184 -0
- opencrbasic/bindings.py +1034 -0
- opencrbasic/catalog.py +173 -0
- opencrbasic/checkconfig.py +144 -0
- opencrbasic/checker/__init__.py +977 -0
- opencrbasic/checker/cr3xx.py +3118 -0
- opencrbasic/checker/ess.py +81 -0
- opencrbasic/checker/expressions.py +3581 -0
- opencrbasic/checker/instr_data.py +1802 -0
- opencrbasic/checker/instr_measure.py +1547 -0
- opencrbasic/checker/instr_rules.py +3540 -0
- opencrbasic/checker/instructions.py +17 -0
- opencrbasic/checker/intervals.py +678 -0
- opencrbasic/checker/longconv.py +133 -0
- opencrbasic/checker/scan_time.py +580 -0
- opencrbasic/checker/storage.py +456 -0
- opencrbasic/checker/structs.py +240 -0
- opencrbasic/checker/structure.py +6213 -0
- opencrbasic/checker/symbols.py +609 -0
- opencrbasic/checker/terminals.py +249 -0
- opencrbasic/checker/unused.py +186 -0
- opencrbasic/checker/var_types.py +564 -0
- opencrbasic/checker/warnings.py +661 -0
- opencrbasic/checks.py +491 -0
- opencrbasic/cli.py +391 -0
- opencrbasic/commands.py +128 -0
- opencrbasic/compiler.py +320 -0
- opencrbasic/consteval.py +327 -0
- opencrbasic/data/background_calibration.json +4 -0
- opencrbasic/data/before_beginprog.json +99 -0
- opencrbasic/data/cr1000x/background_calibration.json +4 -0
- opencrbasic/data/cr1000x/before_beginprog.json +138 -0
- opencrbasic/data/cr1000x/ess_variables.json +1087 -0
- opencrbasic/data/cr1000x/fnotch.json +29 -0
- opencrbasic/data/cr1000x/functions.json +1337 -0
- opencrbasic/data/cr1000x/instructions.json +4836 -0
- opencrbasic/data/cr1000x/measure.json +121 -0
- opencrbasic/data/cr1000x/mode.json +111 -0
- opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
- opencrbasic/data/cr1000x/settings_fields.json +321 -0
- opencrbasic/data/cr1000x/status_members.json +318 -0
- opencrbasic/data/cr1000x/symbols.json +872 -0
- opencrbasic/data/cr1000x/terminals.json +225 -0
- opencrbasic/data/cr300/before_beginprog.json +42 -0
- opencrbasic/data/cr300/ess_variables.json +1087 -0
- opencrbasic/data/cr300/fnotch.json +22 -0
- opencrbasic/data/cr300/functions.json +1261 -0
- opencrbasic/data/cr300/instructions.json +2837 -0
- opencrbasic/data/cr300/measure.json +33 -0
- opencrbasic/data/cr300/mode.json +121 -0
- opencrbasic/data/cr300/settings_fields.json +259 -0
- opencrbasic/data/cr300/status_members.json +303 -0
- opencrbasic/data/cr300/symbols.json +584 -0
- opencrbasic/data/cr300/terminals.json +193 -0
- opencrbasic/data/cr350/before_beginprog.json +44 -0
- opencrbasic/data/cr350/ess_variables.json +1087 -0
- opencrbasic/data/cr350/fnotch.json +22 -0
- opencrbasic/data/cr350/functions.json +1262 -0
- opencrbasic/data/cr350/instructions.json +2848 -0
- opencrbasic/data/cr350/measure.json +35 -0
- opencrbasic/data/cr350/mode.json +121 -0
- opencrbasic/data/cr350/settings_fields.json +292 -0
- opencrbasic/data/cr350/status_members.json +303 -0
- opencrbasic/data/cr350/symbols.json +572 -0
- opencrbasic/data/cr350/terminals.json +190 -0
- opencrbasic/data/ess_variables.json +1087 -0
- opencrbasic/data/fnotch.json +34 -0
- opencrbasic/data/functions.json +1337 -0
- opencrbasic/data/instructions.json +4965 -0
- opencrbasic/data/measure.json +123 -0
- opencrbasic/data/mode.json +121 -0
- opencrbasic/data/notices_cr6.json +890 -0
- opencrbasic/data/pipeline_tasks.json +84 -0
- opencrbasic/data/settings_fields.json +322 -0
- opencrbasic/data/status_members.json +319 -0
- opencrbasic/data/status_table.json +684 -0
- opencrbasic/data/symbols.json +902 -0
- opencrbasic/data/terminals.json +195 -0
- opencrbasic/data/type_codes.json +249 -0
- opencrbasic/diagnostics.py +196 -0
- opencrbasic/editing.py +827 -0
- opencrbasic/formatting.py +310 -0
- opencrbasic/helpdocs.py +677 -0
- opencrbasic/hover.py +667 -0
- opencrbasic/inlay.py +169 -0
- opencrbasic/lexer.py +183 -0
- opencrbasic/lsp/__init__.py +5 -0
- opencrbasic/lsp/__main__.py +7 -0
- opencrbasic/lsp/config.py +202 -0
- opencrbasic/lsp/features.py +602 -0
- opencrbasic/lsp/server.py +741 -0
- opencrbasic/lsp/session.py +1189 -0
- opencrbasic/lsp/worker.py +128 -0
- opencrbasic/mode.py +143 -0
- opencrbasic/navigation.py +401 -0
- opencrbasic/parser.py +3264 -0
- opencrbasic/positions.py +274 -0
- opencrbasic/preprocess.py +269 -0
- opencrbasic/profiles.py +266 -0
- opencrbasic/provenance.py +17 -0
- opencrbasic/quickfix.py +331 -0
- opencrbasic/rename.py +662 -0
- opencrbasic/signature.py +33 -0
- opencrbasic/source.py +600 -0
- opencrbasic/sources.py +125 -0
- opencrbasic/syntax.py +337 -0
- opencrbasic/tables.py +2173 -0
- opencrbasic/tables_hook.py +14 -0
- opencrbasic/tdf.py +142 -0
- opencrbasic/timing.py +565 -0
- opencrbasic-1.0.0.dist-info/METADATA +223 -0
- opencrbasic-1.0.0.dist-info/RECORD +117 -0
- opencrbasic-1.0.0.dist-info/WHEEL +4 -0
- opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
- 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
|