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
|
@@ -0,0 +1,3118 @@
|
|
|
1
|
+
"""CR300/CR350-specific checks that do not generalise to CR6/CR1000X: message variants and
|
|
2
|
+
validations these two loggers' own smaller instruction/function set needs. Owned separately from
|
|
3
|
+
the shared `checker/*` modules (`PLAN.md`); `check` below is a no-op on every other logger, so
|
|
4
|
+
`checker/__init__.py`'s own call site can stay a single unconditional line. Rules and oracle
|
|
5
|
+
evidence: `docs/loggers.md`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import collections
|
|
9
|
+
import dataclasses
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Set as AbstractSet
|
|
12
|
+
from typing import TypedDict
|
|
13
|
+
|
|
14
|
+
from .. import bindings, consteval, lexer
|
|
15
|
+
from .. import syntax as S
|
|
16
|
+
from ..diagnostics import Diagnostic, Diagnostics
|
|
17
|
+
from . import expressions, instr_rules, structure, symbols
|
|
18
|
+
|
|
19
|
+
# `SerialOpen`'s own `ComPort` argument (position 0, the only position checked here) is the one
|
|
20
|
+
# `Serial*` function on CR300/CR350 whose compile-time validator additionally requires a genuine
|
|
21
|
+
# compile-time constant, reporting `ComPort 0 illegal.` then `Parameter must be a constant
|
|
22
|
+
# integer: %s.` when it is not -- oracle-confirmed (`docs/loggers.md`): an undefined name there
|
|
23
|
+
# gets the ordinary per-name `%s is not defined.` line(s) first (the same shape every other
|
|
24
|
+
# unresolved-name position on these two loggers already gets), **plus** these two extra lines,
|
|
25
|
+
# which no other `Serial*` function shares (`SerialClose`/`SerialFlush`/`SerialOut`/`SerialIn`/...
|
|
26
|
+
# report only the plain per-name undefined-name line(s) for the same argument shape, nothing
|
|
27
|
+
# else -- oracle-confirmed each individually). A live, declared *variable* in this position also
|
|
28
|
+
# triggers the same two extra lines with no undefined-name line (it is not itself undefined) --
|
|
29
|
+
# "not a compile-time constant" is the real trigger, not "undefined" specifically (oracle:
|
|
30
|
+
# `SerialOpen(MyPort,...)`, `MyPort` a declared `Public`, still gets `ComPort 0 illegal.`/
|
|
31
|
+
# `Parameter must be a constant integer: ...`). Deliberately **not** modelled here: a genuine
|
|
32
|
+
# constant that is merely out of range (oracle: `SerialOpen(99,...)` gives `ComPort 99 illegal.`
|
|
33
|
+
# plus a *different* second line, `No Matching, or out of place ) in expression: ...`, not this
|
|
34
|
+
# one) -- the valid `ComPort` value set for CR300/CR350's own smaller hardware was not oracle-
|
|
35
|
+
# pinned down this round, and guessing it risks flagging a value this project cannot confirm is
|
|
36
|
+
# actually invalid (AGENTS.md: never guess into a false reject). Only the unambiguous "not a
|
|
37
|
+
# constant at all" trigger (an undefined name or a live variable, never a resolvable value) is
|
|
38
|
+
# implemented, which never risks flagging a value the real compiler would accept.
|
|
39
|
+
_SERIALOPEN = "SERIALOPEN"
|
|
40
|
+
_SERIALOPEN_PORTS = {
|
|
41
|
+
"cr6": frozenset(range(1, 19)) | frozenset(range(31, 100)),
|
|
42
|
+
"cr1000x": frozenset(range(1, 15)) | frozenset(range(31, 100)),
|
|
43
|
+
}
|
|
44
|
+
_COMPORT_ARG_POSITION = 0
|
|
45
|
+
# Every `Serial*` function whose first argument is a `ComPort` (`data/<logger>/functions.json`:
|
|
46
|
+
# same `argtypes[0]` shape as `SerialOpen`'s own position 0) -- oracle-confirmed for `SerialOpen`/
|
|
47
|
+
# `SerialClose`/`SerialFlush`/`SerialOut` individually; `SerialIn`/`SerialInBlock`/`SerialInChk`/
|
|
48
|
+
# `SerialBrk` added on the same documented convention (a `Serial*` function's own first argument
|
|
49
|
+
# is always its `ComPort`, per every CHM `Syntax` line for this family), not each individually
|
|
50
|
+
# oracle-probed -- safe either way, since an inclusion here only ever *adds* an already-correct-
|
|
51
|
+
# for-`SerialOpen`-style undefined-name diagnostic (never the `SerialOpen`-only ComPort/constant
|
|
52
|
+
# pair, gated separately below), so a wrong inclusion costs nothing worse than under-reporting.
|
|
53
|
+
_SERIAL_FUNCTIONS_WITH_COMPORT_ARG = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"SERIALOPEN",
|
|
56
|
+
"SERIALCLOSE",
|
|
57
|
+
"SERIALFLUSH",
|
|
58
|
+
"SERIALOUT",
|
|
59
|
+
"SERIALIN",
|
|
60
|
+
"SERIALINBLOCK",
|
|
61
|
+
"SERIALINCHK",
|
|
62
|
+
"SERIALBRK",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Baud rates `SerialOpen` accepts on `ComRS232` (oracle, 2026-10-01; CR6 from the decompiled
|
|
68
|
+
# `FUN_004735b0`, which compares the absolute value: 300 only on ports above ComRS232, 230400 and
|
|
69
|
+
# 460800 only on it). CR300/CR350 also reject 300, 600 and any negative rate.
|
|
70
|
+
_BAUDS = frozenset({0, 600, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 76800, 115200, 230400, 460800})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# CR6/CR1000X `SerialOpen` CommsMode values per control port (oracle, 2026-10-24, round 34, 0..20
|
|
74
|
+
# and -1 swept): ComC1/ComC5 take 0..7, ComC3/ComC7 0..4 and 6, else an error; the RS232, ME, U
|
|
75
|
+
# and SDC ports warn for anything but 1 and 2. The port is the predefined constant's value.
|
|
76
|
+
_COMMS_MODES = {
|
|
77
|
+
"COMC1": frozenset(range(8)),
|
|
78
|
+
"COMC5": frozenset(range(8)),
|
|
79
|
+
"COMC3": frozenset({0, 1, 2, 3, 4, 6}),
|
|
80
|
+
"COMC7": frozenset({0, 1, 2, 3, 4, 6}),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _commsmode_problem(
|
|
85
|
+
stmt: S.Call, profile, consts: dict | None, index: int = 5, mode: object = None
|
|
86
|
+
) -> tuple[bool, str] | None:
|
|
87
|
+
"""`index` is the CommsMode argument, `mode` a value to use instead of it."""
|
|
88
|
+
if len(stmt.args) <= index or stmt.args[index] is None:
|
|
89
|
+
return None
|
|
90
|
+
env = consteval.Env(profile, dict(consts or {}))
|
|
91
|
+
try:
|
|
92
|
+
port = consteval.value_of(stmt.args[0], env)
|
|
93
|
+
if mode is None:
|
|
94
|
+
mode = consteval.value_of(stmt.args[index], env)
|
|
95
|
+
except consteval.NotConstant, consteval.DivideByZero, TypeError, ValueError:
|
|
96
|
+
return None
|
|
97
|
+
if isinstance(mode, bool) or not isinstance(mode, (int, float)) or mode != int(mode):
|
|
98
|
+
return None
|
|
99
|
+
for key, modes in _COMMS_MODES.items():
|
|
100
|
+
record = profile.symbols.get(key)
|
|
101
|
+
if record and record["value"] == port:
|
|
102
|
+
return (
|
|
103
|
+
(True, f"CommsMode Option {int(mode)} not supported on {record['name']}.")
|
|
104
|
+
if int(mode) not in modes
|
|
105
|
+
else None
|
|
106
|
+
)
|
|
107
|
+
if int(mode) in (1, 2):
|
|
108
|
+
return None
|
|
109
|
+
for key, record in profile.symbols.items():
|
|
110
|
+
if record["value"] == port and (key == "COMRS232" or key == "COMME" or re.fullmatch(r"COM(U|SDC)\d+", key)):
|
|
111
|
+
# a warning only (`COM232`, `COMME` as printed)
|
|
112
|
+
shown = {"COMRS232": "COM232", "COMME": "COMME"}.get(key, record["name"])
|
|
113
|
+
return False, f"CommsMode Option {int(mode)} not supported on {shown}."
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def bare_unknown_delay(stmt: S.Call, known: set[str]) -> bool:
|
|
118
|
+
"""CR6/CR1000X `SerialOpen` whose 4th argument is a bare undeclared name and that has a 5th."""
|
|
119
|
+
return len(stmt.args) >= 5 and isinstance(stmt.args[3], S.Name) and stmt.args[3].name.upper() not in known
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _format_needs_com1_4(stmt: S.Call, profile, consts: dict | None) -> bool:
|
|
123
|
+
"""CR6/CR1000X: an integer `SerialOpenFormat` of 16 or more on a port numbered 1..8 (ComRS232,
|
|
124
|
+
ComME, the SDC ports; oracle, round 58, docs/checker.md#serialopen-format-options-round-58)."""
|
|
125
|
+
env = consteval.Env(profile, dict(consts or {}))
|
|
126
|
+
try:
|
|
127
|
+
port, fmt = consteval.value_of(stmt.args[0], env), consteval.value_of(stmt.args[2], env)
|
|
128
|
+
except consteval.NotConstant, consteval.DivideByZero, TypeError, ValueError:
|
|
129
|
+
return False
|
|
130
|
+
if not isinstance(port, int) or not isinstance(fmt, int) or isinstance(port, bool) or isinstance(fmt, bool):
|
|
131
|
+
return False
|
|
132
|
+
return 1 <= port <= 8 and 16 <= fmt < 2**31
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _non_numeric(expr, profile, consts: dict | None, variable_names) -> bool:
|
|
136
|
+
if expressions._variable_refs(expr, variable_names):
|
|
137
|
+
return True
|
|
138
|
+
try:
|
|
139
|
+
value = consteval.value_of(expr, consteval.Env(profile, dict(consts or {})))
|
|
140
|
+
except consteval.NotConstant, consteval.DivideByZero, TypeError, ValueError:
|
|
141
|
+
return False
|
|
142
|
+
# no constant integer: a string, a float literal, or beyond 32 bits (round 58)
|
|
143
|
+
return isinstance(value, str) or expressions._not_constant_integer(expr)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _check_baud(stmt: S.Call, profile, diags: Diagnostics, consts: dict | None = None) -> None:
|
|
147
|
+
port = stmt.args[0] if stmt.args else None
|
|
148
|
+
rs232 = isinstance(port, S.Name) and port.name.upper() == "COMRS232"
|
|
149
|
+
# CR6/CR1000X check every valid port (oracle, 2026-10-25, round 35: ComUSB, ComC1, ComSDC7,
|
|
150
|
+
# ComME, 32); an unknown port name is `ComPort 0 illegal.` only
|
|
151
|
+
known_port = isinstance(port, S.Number) or (
|
|
152
|
+
isinstance(port, S.Name)
|
|
153
|
+
and (port.name.upper() in profile.symbols or port.name.upper() in {str(c).upper() for c in consts or {}})
|
|
154
|
+
)
|
|
155
|
+
if len(stmt.args) < 2 or not (rs232 or (not profile.single_pass and known_port)):
|
|
156
|
+
return
|
|
157
|
+
arg = stmt.args[1]
|
|
158
|
+
sign = 1
|
|
159
|
+
if isinstance(arg, S.Unary) and arg.op == "-":
|
|
160
|
+
sign, arg = -1, arg.operand
|
|
161
|
+
if not (isinstance(arg, S.Number) and isinstance(arg.value, int)):
|
|
162
|
+
# a constant expression is evaluated (oracle, all four loggers, 2026-10-10: `Hr` -> `4 is
|
|
163
|
+
# illegal baud rate.`, `Hr+1` -> 5, `True` -> -1, `uSec` -> 0 is fine; a string is accepted)
|
|
164
|
+
try:
|
|
165
|
+
value = consteval.value_of(stmt.args[1], consteval.Env(profile, dict(consts or {})))
|
|
166
|
+
except consteval.NotConstant, TypeError, ValueError, ZeroDivisionError:
|
|
167
|
+
return
|
|
168
|
+
if isinstance(arg, S.Number) or isinstance(value, str) or value != int(value):
|
|
169
|
+
return
|
|
170
|
+
arg, sign = S.Number(stmt.line, stmt.file, abs(int(value)), str(abs(int(value)))), (-1 if value < 0 else 1)
|
|
171
|
+
baud = sign * arg.value
|
|
172
|
+
if not -(2**31) <= baud < 2**31:
|
|
173
|
+
return # `Parameter must be integral.` instead (`checker/intervals.py`)
|
|
174
|
+
if profile.name in ("cr300", "cr350"):
|
|
175
|
+
ok = baud in _BAUDS - {600}
|
|
176
|
+
else:
|
|
177
|
+
ok = abs(baud) in _BAUDS
|
|
178
|
+
if not ok:
|
|
179
|
+
diags.error(stmt.line, f"{baud} is illegal baud rate.", stmt.file)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_dropped_tail(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
183
|
+
"""CR300/CR350: the second, side-by-side expression of an unclosed `Abs`-like call (`Y = Abs(X
|
|
184
|
+
Q`, `If Abs(X Q Then`, `Abs(X Then`) is name-resolved, unlike on CR6/CR1000X: `Q is not defined.`,
|
|
185
|
+
`Then is not defined.` (oracle, 2026-10-12, `docs/checker.md` "Round 15"). The parser drops it
|
|
186
|
+
from the statement and keeps the tokens in `dropped_tail`; a call (`Q(1)`) or a dotted name in
|
|
187
|
+
it is left alone (those shapes have their own messages)."""
|
|
188
|
+
if profile.name not in ("cr300", "cr350"):
|
|
189
|
+
return
|
|
190
|
+
known = None
|
|
191
|
+
for stmt in program.statements:
|
|
192
|
+
tail = getattr(stmt, "dropped_tail", None)
|
|
193
|
+
if not tail or any(t.kind == "OP" and t.text in ("(", ".", ",") for t in tail):
|
|
194
|
+
continue
|
|
195
|
+
if known is None:
|
|
196
|
+
known = expressions._known_names(program, profile)
|
|
197
|
+
for t in tail:
|
|
198
|
+
if t.kind == "IDENT" and t.upper not in known:
|
|
199
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=t.text), stmt.file)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def check(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
203
|
+
"""`SerialOpen`'s `ComPort`: a string or variable on all four loggers; on CR300/CR350 also an
|
|
204
|
+
undefined name, and the other `Serial*` functions' undefined first argument. Oracle-confirmed
|
|
205
|
+
**ungated** (fires
|
|
206
|
+
alongside unrelated pass-1/pass-2 errors elsewhere in the same file, `docs/loggers.md`:
|
|
207
|
+
`acceptdatarecords_2.CR6`'s own `SerialOpen(COMC1,...)` diagnostic prints together with a
|
|
208
|
+
separate, later `CallTable`-shaped issue in the same file) -- called unconditionally,
|
|
209
|
+
alongside `expressions.check_consts`.
|
|
210
|
+
"""
|
|
211
|
+
cr3 = profile.name in ("cr300", "cr350")
|
|
212
|
+
known = expressions._known_names(program, profile)
|
|
213
|
+
variable_names = symbols.declared_variable_names(program)
|
|
214
|
+
for stmt in program.statements:
|
|
215
|
+
if not isinstance(stmt, S.Call):
|
|
216
|
+
continue
|
|
217
|
+
name = stmt.name.upper()
|
|
218
|
+
if name not in _SERIAL_FUNCTIONS_WITH_COMPORT_ARG:
|
|
219
|
+
continue
|
|
220
|
+
if name == _SERIALOPEN and not stmt.explicit and not stmt.args:
|
|
221
|
+
diags.error(
|
|
222
|
+
stmt.line, "ComPort 0 illegal.", stmt.file
|
|
223
|
+
) # bare `SerialOpen[()]`, all four loggers (oracle, 2026-10-08)
|
|
224
|
+
if _COMPORT_ARG_POSITION >= len(stmt.args) or stmt.args[_COMPORT_ARG_POSITION] is None:
|
|
225
|
+
continue
|
|
226
|
+
arg = stmt.args[_COMPORT_ARG_POSITION]
|
|
227
|
+
names = expressions._undefined_names(arg, known)
|
|
228
|
+
for undefined_name in names if cr3 else []:
|
|
229
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=undefined_name), stmt.file)
|
|
230
|
+
if name != _SERIALOPEN:
|
|
231
|
+
continue
|
|
232
|
+
is_variable = bool(expressions._variable_refs(arg, variable_names))
|
|
233
|
+
ports = _SERIALOPEN_PORTS.get(profile.name)
|
|
234
|
+
if ports and not (names or is_variable or isinstance(arg, S.String)):
|
|
235
|
+
# a constant ComPort outside the port codes ends the statement (oracle, round 36/37,
|
|
236
|
+
# docs/checker.md#serialopen-comport-codes-round-37)
|
|
237
|
+
try:
|
|
238
|
+
port = consteval.value_of(arg, consteval.Env(profile, dict(program.consts or {})))
|
|
239
|
+
except consteval.NotConstant, TypeError, ValueError, ZeroDivisionError:
|
|
240
|
+
port = None
|
|
241
|
+
if consteval.is_finite_number(port) and not isinstance(port, bool):
|
|
242
|
+
if port != int(port) or not -(2**31) <= port < 2**31:
|
|
243
|
+
diags.error(stmt.line, "ComPort 0 illegal.", stmt.file)
|
|
244
|
+
diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}.", stmt.file)
|
|
245
|
+
continue
|
|
246
|
+
if int(port) not in ports:
|
|
247
|
+
diags.error(stmt.line, f"ComPort {int(port)} illegal.", stmt.file)
|
|
248
|
+
diags.error(stmt.line, f"No Matching, or out of place ) in expression: {stmt.text}.", stmt.file)
|
|
249
|
+
continue
|
|
250
|
+
_check_baud(stmt, profile, diags, program.consts)
|
|
251
|
+
constant_port = not (names or is_variable or isinstance(arg, S.String))
|
|
252
|
+
# CR6/CR1000X: a String or variable `SerialOpenFormat` makes the 5th argument the CommsMode, with
|
|
253
|
+
# no TXDelay range and at most 5 arguments (oracle, 2026-10-28, round 57,
|
|
254
|
+
# docs/checker.md#serialopen-format-and-commsmode-round-57)
|
|
255
|
+
shifted = (
|
|
256
|
+
not cr3
|
|
257
|
+
and constant_port
|
|
258
|
+
and len(stmt.args) > 2
|
|
259
|
+
and stmt.args[2] is not None
|
|
260
|
+
and _non_numeric(stmt.args[2], profile, program.consts, variable_names)
|
|
261
|
+
)
|
|
262
|
+
# a bare undeclared TXDelay is lost in the same comma recovery (decompiled `FUN_00494aa0`,
|
|
263
|
+
# oracle round 58, docs/checker.md#serialopen-recovery-round-58)
|
|
264
|
+
shifted = shifted or (not cr3 and constant_port and bare_unknown_delay(stmt, known))
|
|
265
|
+
if (
|
|
266
|
+
not cr3
|
|
267
|
+
and constant_port
|
|
268
|
+
and not shifted
|
|
269
|
+
and stmt.args[2:3]
|
|
270
|
+
and stmt.args[2] is not None
|
|
271
|
+
and _format_needs_com1_4(stmt, profile, program.consts)
|
|
272
|
+
):
|
|
273
|
+
diags.error(stmt.line, "Format option only available on COM1..COM4", stmt.file)
|
|
274
|
+
if constant_port and not shifted and len(stmt.args) > 3 and stmt.args[3] is not None:
|
|
275
|
+
# `TXDelay` (oracle, all four loggers, 2026-10-12): a constant outside 0..14000000 is
|
|
276
|
+
# `%d usec delay before transmit is illegal, must be 0..14000000`; a variable is
|
|
277
|
+
# `Parameter must be a constant integer: <statement>.`
|
|
278
|
+
delay = stmt.args[3]
|
|
279
|
+
if expressions._variable_refs(delay, variable_names):
|
|
280
|
+
sep = "\n" if cr3 and stmt.at_eol else ""
|
|
281
|
+
diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}{sep}.", stmt.file)
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
value = consteval.value_of(delay, consteval.Env(profile, dict(program.consts or {})))
|
|
285
|
+
except consteval.NotConstant, TypeError, ValueError, ZeroDivisionError:
|
|
286
|
+
value = None
|
|
287
|
+
if (
|
|
288
|
+
consteval.is_finite_number(value)
|
|
289
|
+
and not isinstance(value, bool)
|
|
290
|
+
and value == int(value)
|
|
291
|
+
and -(2**31) <= value < 2**31
|
|
292
|
+
and not 0 <= value <= 14000000
|
|
293
|
+
):
|
|
294
|
+
# (beyond 32 bits: `Parameter must be a constant integer` only, `intervals.py`)
|
|
295
|
+
diags.error(
|
|
296
|
+
stmt.line, f"{int(value)} usec delay before transmit is illegal, must be 0..14000000", stmt.file
|
|
297
|
+
)
|
|
298
|
+
# a 6th argument that is no constant integer counts as mode 0, then is rejected (same probes)
|
|
299
|
+
bad_mode = (
|
|
300
|
+
not cr3
|
|
301
|
+
and constant_port
|
|
302
|
+
and not shifted
|
|
303
|
+
and len(stmt.args) > 5
|
|
304
|
+
and stmt.args[5] is not None
|
|
305
|
+
and (
|
|
306
|
+
expressions._not_constant_integer(stmt.args[5])
|
|
307
|
+
or bool(expressions._variable_refs(stmt.args[5], variable_names))
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
if (
|
|
311
|
+
not cr3
|
|
312
|
+
and constant_port
|
|
313
|
+
and (
|
|
314
|
+
problem := _commsmode_problem(
|
|
315
|
+
stmt, profile, program.consts, 4 if shifted else 5, mode=0 if bad_mode else None
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
):
|
|
319
|
+
if problem[0]:
|
|
320
|
+
diags.error(stmt.line, problem[1], stmt.file)
|
|
321
|
+
else:
|
|
322
|
+
diags.warning(stmt.line, problem[1], stmt.file)
|
|
323
|
+
if bad_mode:
|
|
324
|
+
diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}.", stmt.file)
|
|
325
|
+
continue
|
|
326
|
+
if shifted and len(stmt.args) > 5:
|
|
327
|
+
diags.error(stmt.line, f"No Matching, or out of place ) in expression: {stmt.text}.", stmt.file)
|
|
328
|
+
continue
|
|
329
|
+
if (
|
|
330
|
+
not (cr3 or names or is_variable or isinstance(arg, S.String))
|
|
331
|
+
and len(stmt.args) > 4
|
|
332
|
+
and expressions._variable_refs(stmt.args[4], variable_names)
|
|
333
|
+
):
|
|
334
|
+
# a variable `BufferSize` (oracle, CR6/CR1000X, 2026-10-24, round 34)
|
|
335
|
+
diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}.", stmt.file)
|
|
336
|
+
continue
|
|
337
|
+
if names or is_variable or isinstance(arg, S.String):
|
|
338
|
+
# A string or variable ComPort, all four loggers, pass 1 (oracle, 2026-10-01:
|
|
339
|
+
# `SerialOpen("fuzz",...)`). CR300/CR350 echo the line end (`docs/loggers.md`).
|
|
340
|
+
diags.error(stmt.line, "ComPort 0 illegal.", stmt.file)
|
|
341
|
+
if not cr3 and names and not is_variable:
|
|
342
|
+
continue # CR6/CR1000X: an undefined name gives only this line (docs/checker.md)
|
|
343
|
+
sep = "\n" if cr3 and stmt.at_eol else ""
|
|
344
|
+
diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}{sep}.", stmt.file)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# `check_too_many_cascade` (Round 7b, broadened this round -- see `docs/loggers.md` "Too many
|
|
348
|
+
# parameters cascade, widened" for the oracle transcript): once any undefined-name-class error
|
|
349
|
+
# fires anywhere in the file (an instruction-argument or assignment-RHS name `expressions.check`/
|
|
350
|
+
# `check_consts` could not resolve, the const-family "rejects a variable" `Variable illegal in
|
|
351
|
+
# Parameter: %s.` finding, **or now also a genuine `Too many parameters: %s.` finding from
|
|
352
|
+
# `instr_rules.check_too_many` itself** -- oracle-confirmed self-chaining, scratch probe `t3.cr6`/
|
|
353
|
+
# `t4.cr6`: a real `PortSet(C1,1,2,3)` excess taints the *next* statement the same way an undefined
|
|
354
|
+
# name does), CR300/CR350's own parser leaves some shared state corrupted for the **rest of the
|
|
355
|
+
# file, not just the next statement** (widened from Round 7b's own single-shot model, which a
|
|
356
|
+
# three-and-four-statement scratch chain, `t.cr6`/`t3.cr6`, and the corpus's own `dewpoint_1.CR6`
|
|
357
|
+
# (3 in a row), `runprogram_1.CR6`/`filemanage_1.CR6`/`goesdata_1.CR6` (2 in a row) all show is too
|
|
358
|
+
# narrow -- Round 7b's own corpus sample never happened to have more than one qualifying follow-up
|
|
359
|
+
# statement, so "stops after one" was indistinguishable from "keeps going" until this round):
|
|
360
|
+
# **every** subsequent statement that is a recognised `S.Instruction` written with two or more
|
|
361
|
+
# arguments gets its own spurious `Too many parameters: <its own second argument's verbatim
|
|
362
|
+
# text>.`, `checker/instr_rules.py`'s own real message reused for a shape that instruction's own
|
|
363
|
+
# argument count never earns on its own (`WriteIO(1,2)`, `PortSet(1,2)`, ... all compile clean
|
|
364
|
+
# standalone) -- oracle-confirmed (scratch `t.cr6`: an undefined name followed by three `PortSet`s
|
|
365
|
+
# and a `Delay`, all four flagged, each echoing its own second argument, not the first one skipped
|
|
366
|
+
# over). This **replaces** whatever that tainted statement's own checks would otherwise have found
|
|
367
|
+
# for it, rather than merely filling a silent gap -- oracle-confirmed (`t2.cr6`: `PortSet(C1,
|
|
368
|
+
# BadVar2)` after an earlier undefined name gets only `Too many parameters: BadVar2.`, never a
|
|
369
|
+
# second `BadVar2 is not defined.` of its own; `t3.cr6`: a tainted `PortSet(C1,1,2,3)`, which would
|
|
370
|
+
# genuinely overflow on its own (2 declared params, 4 given), still echoes its own second argument
|
|
371
|
+
# (`1`), never the true excess position (`2`) `instr_rules.check_too_many` would compute for it
|
|
372
|
+
# standalone). Confirmed **not** anchored to the *specific* failing statement -- when several
|
|
373
|
+
# statements in a row each have their own undefined-name error, only the search from the *last* one
|
|
374
|
+
# starts the cascade (untouched from Round 7b) -- and the search itself skips forward, without
|
|
375
|
+
# breaking the chain, over any clean statement that does not itself qualify (fewer than two
|
|
376
|
+
# arguments, or not a recognised instruction at all -- a plain assignment or a one-argument call
|
|
377
|
+
# such as `Battery(BattV)` is silently passed over, not itself flagged and not a stopping point, and
|
|
378
|
+
# neither is an *unresolved* bare call such as `Move(...)`/`Routes(...)` naming an instruction this
|
|
379
|
+
# project's own table has no record of on this logger -- corpus: `routes_1.CR6`, where `MyRoute is
|
|
380
|
+
# not defined.` keeps firing normally on those two lines, untouched by the taint, because neither
|
|
381
|
+
# parses to a recognised `S.Instruction` at all) -- confirmed with a bare assignment and a
|
|
382
|
+
# one-argument call each skipped in the middle, and a `'comment` line skipped for free (comments are
|
|
383
|
+
# never `program.statements` entries at all). **CR6/CR1000X do not share this at all**
|
|
384
|
+
# (oracle-confirmed with the same shape, an undefined const-family name immediately followed by a
|
|
385
|
+
# two-argument instruction: no extra line).
|
|
386
|
+
#
|
|
387
|
+
# Because the taint can originate from a pass-1/ungated finding (`cr3xx.check`'s own `SerialOpen`/
|
|
388
|
+
# `ComPort` finding, or `instr_rules.check_too_many`'s own genuine finding -- corpus:
|
|
389
|
+
# `acceptdatarecords_1.CR6`, `SerialOpen (COMC1,...)`'s `COMC1 is not defined.` taints every
|
|
390
|
+
# following two-plus-argument instruction to end of file, `Scan`'s own `Sec` and
|
|
391
|
+
# `AcceptDataRecords`'s own `32769`, despite that finding being **ungated** and thus, in this
|
|
392
|
+
# project's own pass model, skipping tier B -- where the *previous*, narrower version of this
|
|
393
|
+
# function lived -- entirely), this is no longer called from inside the `if not diags.errors:`
|
|
394
|
+
# tier-B block the way `check_expr_cascade` was: `checker/__init__.py` now calls it once,
|
|
395
|
+
# unconditionally, after every other diagnostic-producing pass has run, so it can see -- and rewrite
|
|
396
|
+
# -- the complete, final diagnostic list regardless of which tier first tainted the file. It is
|
|
397
|
+
# still a no-op when nothing in the file ever taints (no `_is_cascade_trigger` match and no
|
|
398
|
+
# `Too many parameters:` message already present).
|
|
399
|
+
_UNRESOLVED_SUFFIX = " is not defined."
|
|
400
|
+
_LEFTOVER_LEAD_RE = re.compile(r"\s*(?:\(\s*([A-Za-z_]\w*)\s*[,(]|([A-Za-z_]\w*)\s*,)")
|
|
401
|
+
_VARIABLE_ILLEGAL_PREFIX = "Variable illegal in Parameter:"
|
|
402
|
+
_TOO_MANY_PREFIX = "Too many parameters:"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# Declaration errors taint the same way (oracle, CR300 and CR350, 2026-09-27: each of these
|
|
406
|
+
# followed by a clean `Scan (1,Sec,0,0)` gives `Too many parameters: Sec.` on it).
|
|
407
|
+
_DECLARATION_TRIGGERS = (
|
|
408
|
+
"instruction must be in a ConstTable",
|
|
409
|
+
"already declared as a variable.",
|
|
410
|
+
"is already declared as a CONST.",
|
|
411
|
+
"illegal, starts with a number.",
|
|
412
|
+
"not yet declared.",
|
|
413
|
+
"so cannot be aliased.",
|
|
414
|
+
"not supported",
|
|
415
|
+
"Variable not named.",
|
|
416
|
+
" is illegal baud rate.",
|
|
417
|
+
"already defined as an internal function.",
|
|
418
|
+
"is a predefined CONST.",
|
|
419
|
+
"Only one ConstTable allowed.",
|
|
420
|
+
"EndConstTable with no ConstTable.",
|
|
421
|
+
"Missing variable name.",
|
|
422
|
+
"; cannot be numeric.",
|
|
423
|
+
"Too few parameters in instruction.",
|
|
424
|
+
"Const with no assignment.",
|
|
425
|
+
"CONST needs assignment.",
|
|
426
|
+
" unknown Const type",
|
|
427
|
+
"Cannot Alias an Alias.",
|
|
428
|
+
) # (round 31)
|
|
429
|
+
_DECLARATION_TRIGGER_PREFIXES = (
|
|
430
|
+
"Too many Slow Sequences",
|
|
431
|
+
"Duplicate Alias",
|
|
432
|
+
"Need closing )",
|
|
433
|
+
"Illegal END or EXIT ",
|
|
434
|
+
"Illegally dimensioned aliased name",
|
|
435
|
+
"Alias is illegally dimensioned.",
|
|
436
|
+
"Invalid Variable declaration",
|
|
437
|
+
"EndSelect w/o Select",
|
|
438
|
+
"EndStructure without Structure",
|
|
439
|
+
"String without end quote",
|
|
440
|
+
"Call argument required",
|
|
441
|
+
"No Matching, or out of place )",
|
|
442
|
+
"Expression expects ",
|
|
443
|
+
"Incomplete expression:",
|
|
444
|
+
"ESS variable ",
|
|
445
|
+
"Invalid or missing parameter.",
|
|
446
|
+
"Parameter must be a constant integer:",
|
|
447
|
+
"Invalid, or out of place expression:",
|
|
448
|
+
"Invalid assignment in expression:",
|
|
449
|
+
"Illegal destination variable type",
|
|
450
|
+
"Invalid Variable or Array:",
|
|
451
|
+
"Cannot recursively call functions.",
|
|
452
|
+
"Parameter must be integral.",
|
|
453
|
+
"Interval (",
|
|
454
|
+
"Illegally dimension: ",
|
|
455
|
+
"Scan interval must be integrally divisible",
|
|
456
|
+
"Dimensions not declared:",
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _is_short_instruction(stmt, profile) -> bool:
|
|
461
|
+
"""An instruction call written with fewer arguments than it requires."""
|
|
462
|
+
record = profile.instructions.get(stmt.keyword.upper()) if isinstance(stmt, S.Instruction) else None
|
|
463
|
+
return record is not None and instr_rules.given_count(stmt, profile) < instr_rules._required_count(
|
|
464
|
+
instr_rules._real_params(record["params"])
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _is_cascade_trigger(diag) -> bool:
|
|
469
|
+
return (
|
|
470
|
+
diag.severity == "error"
|
|
471
|
+
and diag.cascade
|
|
472
|
+
and (
|
|
473
|
+
diag.trigger
|
|
474
|
+
or diag.message.endswith(_UNRESOLVED_SUFFIX)
|
|
475
|
+
or diag.message == _TCPOPEN_HANDLE
|
|
476
|
+
or diag.message.startswith(_VARIABLE_ILLEGAL_PREFIX)
|
|
477
|
+
or diag.message.startswith(_TOO_MANY_PREFIX)
|
|
478
|
+
or diag.message in ("Miss match of types", "Illegal object id.", "Illegal OID since already declared.")
|
|
479
|
+
or diag.message.startswith("SNMP OID must be greater than") # SNMPVariable (oracle, CR300/CR350)
|
|
480
|
+
or diag.message.endswith(_DECLARATION_TRIGGERS)
|
|
481
|
+
or diag.message.startswith(_DECLARATION_TRIGGER_PREFIXES)
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _reword_prose(program: S.Program, profile, diags: Diagnostics, stmt: S.Unparsed, gone: set[str]) -> None:
|
|
487
|
+
"""A prose line after the cascade has started: a name whose declaration the cascade dropped is
|
|
488
|
+
undefined there too (oracle, CR300, 2026-10-08: `Current` declared after the first error, then
|
|
489
|
+
prose mentioning `current`). Re-derives the leading-word lines of the generic fallback."""
|
|
490
|
+
key = (stmt.line, stmt.file)
|
|
491
|
+
own = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
|
|
492
|
+
echoes = {_echo_key(stmt.text), _echo_key(structure.condition_part(stmt.text) or stmt.text)}
|
|
493
|
+
fb = next(
|
|
494
|
+
(
|
|
495
|
+
i
|
|
496
|
+
for i in own
|
|
497
|
+
if diags.items[i].message.startswith(_GENERIC_FALLBACK_PREFIX)
|
|
498
|
+
and _echo_key(diags.items[i].message[len(_GENERIC_FALLBACK_PREFIX) : -1]) in echoes
|
|
499
|
+
),
|
|
500
|
+
None,
|
|
501
|
+
)
|
|
502
|
+
if fb is None or not all(diags.items[i].message.endswith(_UNRESOLVED_SUFFIX) for i in own if i < fb):
|
|
503
|
+
return
|
|
504
|
+
text = diags.items[fb].message[len(_GENERIC_FALLBACK_PREFIX) : -1]
|
|
505
|
+
lead = _INSTRUCTION_PROSE_RE.match(text)
|
|
506
|
+
info = profile.instructions.get(lead.group(1).upper()) if lead else None
|
|
507
|
+
if _TABLE_LINE_RE.match(text) or (info and info["params"]):
|
|
508
|
+
return
|
|
509
|
+
known = prose_known(program, profile) - declared_later(first_declarations(program), stmt.line, stmt.file) - gone
|
|
510
|
+
words = _leading_unknown_words(text, known)
|
|
511
|
+
old = [diags.items[i].message for i in own if i < fb]
|
|
512
|
+
new = [profile.unresolved_symbol_message.format(name=w) for w in words]
|
|
513
|
+
if old != new:
|
|
514
|
+
fresh = [Diagnostic("error", stmt.line, m, stmt.file, True) for m in new]
|
|
515
|
+
diags.items[own[0] if own else fb : fb] = fresh
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _tainted_prose_instruction(stmt: S.Unparsed, profile) -> str | None:
|
|
519
|
+
"""`""`: the prose is an instruction whose first parameter takes the text unread (no output);
|
|
520
|
+
a token: the surplus second parameter; `None`: not such a line."""
|
|
521
|
+
lead = _INSTRUCTION_PROSE_RE.match(stmt.text or "")
|
|
522
|
+
info = profile.instructions.get(lead.group(1).upper()) if lead else None
|
|
523
|
+
if not lead or not info or not info["params"] or lead.group(1).upper() in ("DATA", "DATALONG"):
|
|
524
|
+
return None
|
|
525
|
+
codes, rest = info["params"], lead.group(2)
|
|
526
|
+
if codes[0] % 100 == 11 and "," not in rest and len(codes) > 1 and codes[1] < 100:
|
|
527
|
+
return ""
|
|
528
|
+
if codes[0] % 100 == 6 and len(codes) > 1:
|
|
529
|
+
name_tok = instr_rules._next_simple_token(rest)
|
|
530
|
+
return instr_rules._next_simple_token(rest[rest.index(name_tok) + len(name_tok) :]) or None
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _inline_owner(statements, index: int):
|
|
535
|
+
"""The one-line `If` whose body holds `statements[index]`, if any (on the same source line,
|
|
536
|
+
however many `:`-separated statements the body has; review R14C-6)."""
|
|
537
|
+
stmt = statements[index]
|
|
538
|
+
for i in range(index - 1, -1, -1):
|
|
539
|
+
prev = statements[i]
|
|
540
|
+
if (prev.line, prev.file) != (stmt.line, stmt.file):
|
|
541
|
+
break
|
|
542
|
+
if isinstance(prev, S.If) and any(x is stmt for x in (prev.inline or []) + (prev.inline_else or [])):
|
|
543
|
+
return prev
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _names_in(cond, profile, message: str) -> bool:
|
|
548
|
+
"""Whether `message` is the unresolved-symbol line for a name written in `cond`."""
|
|
549
|
+
if cond is None:
|
|
550
|
+
return False
|
|
551
|
+
return any(message == profile.unresolved_symbol_message.format(name=n) for n in _expr_names(cond))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def check_too_many_cascade(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
555
|
+
"""CR300/CR350 only; a no-op on every other logger and when nothing in the file ever taints
|
|
556
|
+
(see this module's comment above for the oracle evidence and the exact shape implemented).
|
|
557
|
+
Called from `checker/__init__.py` once, unconditionally, after every other pass -- see that
|
|
558
|
+
comment for why this can no longer be tier-B-only the way `check_expr_cascade` was.
|
|
559
|
+
"""
|
|
560
|
+
if profile.name not in ("cr300", "cr350"):
|
|
561
|
+
return
|
|
562
|
+
# A single left-to-right sweep, **not** "find the last trigger line, then walk forward from
|
|
563
|
+
# it": `tainted` latches on the first qualifying statement whose own (pre-existing, untouched)
|
|
564
|
+
# diagnostics are trigger-shaped, and then stays on for every remaining statement, rewriting
|
|
565
|
+
# each qualifying one in turn -- oracle-confirmed this is the right shape, not a re-scan, by
|
|
566
|
+
# `t2.cr6` (`docs/loggers.md`): a tainted `PortSet(C1,BadVar2)` is *itself* trigger-shaped (its
|
|
567
|
+
# own, pre-taint `BadVar2 is not defined.`), but must still be **rewritten**, not treated as a
|
|
568
|
+
# fresh anchor that leaves it untouched and only starts the chain after it -- a "find the last
|
|
569
|
+
# trigger" re-scan gets this backwards (picks that statement as the new anchor and leaves its
|
|
570
|
+
# own stale diagnostic standing). A statement is only ever left as the untouched anchor when
|
|
571
|
+
# `tainted` was still `False` on entry to it (the *first* trigger in the file, matching
|
|
572
|
+
# `dewpoint_1.CR6`'s own first `VoltSE` keeping its real `mv5000C is not defined.` pair while
|
|
573
|
+
# its identical-shaped successor is swallowed).
|
|
574
|
+
tainted = False
|
|
575
|
+
taint_at = 0
|
|
576
|
+
stash: dict[tuple, list] = {} # inline-`If` condition diagnostics set aside while the body is rewritten
|
|
577
|
+
inline_bodies = {
|
|
578
|
+
id(b) for st in program.statements if isinstance(st, S.If) for b in (st.inline or []) + (st.inline_else or [])
|
|
579
|
+
}
|
|
580
|
+
dropped: set[int] = set() # tainted declarations: their names are never registered
|
|
581
|
+
tainted_subs: set[int] = set() # tainted `Sub`/`Function` headers: parameters dropped
|
|
582
|
+
rewritten: set[int] = set()
|
|
583
|
+
dropped_names: set[str] = set()
|
|
584
|
+
registered_names: set[str] = set()
|
|
585
|
+
include_errors = sorted(program.include_error_at) # `Include` extension / nesting errors trigger it too
|
|
586
|
+
for index, stmt in enumerate(program.statements):
|
|
587
|
+
key = (stmt.line, stmt.file)
|
|
588
|
+
if not tainted and include_errors and include_errors[0] <= index:
|
|
589
|
+
tainted = True # (oracle, 2026-10-12: the statements after the error are rewritten)
|
|
590
|
+
taint_at = index
|
|
591
|
+
if not tainted and isinstance(stmt, S.Declaration):
|
|
592
|
+
registered_names.update(v.name.upper() for v in stmt.vars)
|
|
593
|
+
existing = [d for d in diags.items if (d.line, d.file) == key]
|
|
594
|
+
if isinstance(stmt, S.If) and stmt.inline is not None: # (a body hoisted out of it too)
|
|
595
|
+
# only the condition's own lines belong to the `If`; the body's are its statements'
|
|
596
|
+
existing = [d for d in existing if _names_in(stmt.cond, profile, d.message)]
|
|
597
|
+
elif (owner0 := _inline_owner(program.statements, index)) is not None and not tainted:
|
|
598
|
+
existing = [d for d in existing if not _names_in(owner0.cond, profile, d.message)]
|
|
599
|
+
if tainted:
|
|
600
|
+
owner = _inline_owner(program.statements, index)
|
|
601
|
+
if owner is not None:
|
|
602
|
+
# the body of a one-line `If` is rewritten like any statement, the condition's own
|
|
603
|
+
# `X is not defined.` lines stay (oracle, CR300/CR350, 2026-10-12)
|
|
604
|
+
held = [d for d in existing if d.severity == "error" and _names_in(owner.cond, profile, d.message)]
|
|
605
|
+
if held:
|
|
606
|
+
stash.setdefault(key, []).extend(held)
|
|
607
|
+
diags.items[:] = [d for d in diags.items if not any(d is h for h in held)]
|
|
608
|
+
if (
|
|
609
|
+
isinstance(stmt, S.Call)
|
|
610
|
+
and stmt.name.upper() == "SERIALOPEN"
|
|
611
|
+
and len(stmt.args) > 1
|
|
612
|
+
and isinstance(stmt.args[1], (S.Unary, S.Binary))
|
|
613
|
+
):
|
|
614
|
+
# a negative or computed baud is swallowed once the cascade has started, a plain
|
|
615
|
+
# illegal one still prints (oracle, CR300/CR350, 2026-10-10)
|
|
616
|
+
diags.items[:] = [
|
|
617
|
+
d
|
|
618
|
+
for d in diags.items
|
|
619
|
+
if (d.line, d.file) != key or not d.message.endswith(" is illegal baud rate.")
|
|
620
|
+
]
|
|
621
|
+
lead = _LEFTOVER_LEAD_RE.match(stmt.text) if isinstance(stmt, S.Unparsed) else None
|
|
622
|
+
lead_name = (lead.group(1) or lead.group(2)) if lead else None
|
|
623
|
+
if lead_name and lead_name.upper() in dropped_names - registered_names:
|
|
624
|
+
# a missing-parameter leftover `(Name,1,...)` whose first name is a cascade-dropped
|
|
625
|
+
# declaration reports it first (oracle, CR300/CR350, 2026-10-08)
|
|
626
|
+
at = next(
|
|
627
|
+
(
|
|
628
|
+
i
|
|
629
|
+
for i, d in enumerate(diags.items)
|
|
630
|
+
if (d.line, d.file) == key and d.message.startswith(_GENERIC_FALLBACK_PREFIX)
|
|
631
|
+
),
|
|
632
|
+
None,
|
|
633
|
+
)
|
|
634
|
+
if at is not None:
|
|
635
|
+
diags.items.insert(
|
|
636
|
+
at,
|
|
637
|
+
Diagnostic(
|
|
638
|
+
"error", stmt.line, profile.unresolved_symbol_message.format(name=lead_name), stmt.file
|
|
639
|
+
),
|
|
640
|
+
)
|
|
641
|
+
continue
|
|
642
|
+
if isinstance(stmt, S.Unparsed) and dropped_names - registered_names:
|
|
643
|
+
_reword_prose(program, profile, diags, stmt, dropped_names - registered_names)
|
|
644
|
+
if isinstance(stmt, S.Unparsed) and (_TABLE_LINE_RE.match(stmt.text) or _SIGNED_TABLE_RE.match(stmt.text)):
|
|
645
|
+
diags.items[:] = [
|
|
646
|
+
d
|
|
647
|
+
for d in diags.items
|
|
648
|
+
if (d.line, d.file) != key
|
|
649
|
+
or not (
|
|
650
|
+
d.message.startswith("Could not find DataTable ")
|
|
651
|
+
or d.message == "Expr illegally occurs before BeginProg."
|
|
652
|
+
or (d.message.startswith(_GENERIC_FALLBACK_PREFIX) and stmt.text.strip() in d.message)
|
|
653
|
+
)
|
|
654
|
+
]
|
|
655
|
+
continue
|
|
656
|
+
if isinstance(stmt, S.Declaration) or (
|
|
657
|
+
isinstance(stmt, S.Unparsed) and stmt.text.lstrip().upper().startswith(("PUBLIC ", "DIM "))
|
|
658
|
+
):
|
|
659
|
+
# A tainted declaration's own error is dropped (oracle: `Public A As StringX * 50`
|
|
660
|
+
# then `Dim Z banana` prints no `Invalid Variable declaration banana.`); one with
|
|
661
|
+
# two or more variables reads like an instruction (`Dim A(), B` -> `Too many
|
|
662
|
+
# parameters: B.`).
|
|
663
|
+
if not (isinstance(stmt, S.Declaration) and not stmt.vars):
|
|
664
|
+
# (`Public` alone keeps `Variable not named.`: oracle, 2026-10-01, `asin_1`)
|
|
665
|
+
diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
|
|
666
|
+
if isinstance(stmt, S.Declaration) and len(stmt.vars) >= 2:
|
|
667
|
+
diags.error(stmt.line, f"Too many parameters: {stmt.vars[1].name}.", stmt.file)
|
|
668
|
+
if isinstance(stmt, S.Declaration) and stmt.kind in ("PUBLIC", "DIM", "STATIC"):
|
|
669
|
+
dropped.add(id(stmt))
|
|
670
|
+
dropped_names.update(v.name.upper() for v in stmt.vars)
|
|
671
|
+
continue
|
|
672
|
+
alias_src = None
|
|
673
|
+
if (
|
|
674
|
+
isinstance(stmt, S.Instruction)
|
|
675
|
+
and stmt.keyword.upper() == "ALIAS"
|
|
676
|
+
and stmt.args
|
|
677
|
+
and isinstance(stmt.args[0], (S.Index, S.Name))
|
|
678
|
+
):
|
|
679
|
+
alias_src = stmt.args[0].name
|
|
680
|
+
elif isinstance(stmt, S.Unparsed): # `Alias A(1) =` with the name on the next line
|
|
681
|
+
m_alias = re.match(r"(?i)\s*Alias\s+([A-Za-z_]\w*)\s*(?:\([^()]*\))?\s*=\s*$", stmt.text)
|
|
682
|
+
alias_src = m_alias.group(1) if m_alias else None
|
|
683
|
+
if alias_src and alias_src.upper() in dropped_names - registered_names:
|
|
684
|
+
# a cascade-dropped declaration is never registered, so its aliases name an
|
|
685
|
+
# undeclared source (oracle, CR300/CR350, 2026-10-04)
|
|
686
|
+
diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
|
|
687
|
+
at = next(
|
|
688
|
+
(i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line),
|
|
689
|
+
len(diags.items),
|
|
690
|
+
)
|
|
691
|
+
diags.items.insert(
|
|
692
|
+
at, Diagnostic("error", stmt.line, f"{alias_src} not yet declared so cannot be aliased.", stmt.file)
|
|
693
|
+
)
|
|
694
|
+
# the alias name is not registered either (prose that mentions it: `current`)
|
|
695
|
+
if isinstance(stmt, S.Instruction):
|
|
696
|
+
dropped_names.update(a.name.upper() for a in stmt.args[1:] if isinstance(a, S.Name))
|
|
697
|
+
continue
|
|
698
|
+
if isinstance(stmt, S.SubDef):
|
|
699
|
+
tainted_subs.add(id(stmt))
|
|
700
|
+
# `Sub Foo (A, B)` -> `Too many parameters: B.`, `Function F (x) As Long` ->
|
|
701
|
+
# `... As.`: the second token after the name (oracle, 2026-09-28)
|
|
702
|
+
m = re.search(r"\((.*)$", stmt.text or "") # `(x) As Long` -> x, As
|
|
703
|
+
# a parameter's own dimensions are no token (oracle, 2026-10-22: a tainted
|
|
704
|
+
# `Sub T(Q(-1))` or `Sub T(Q(K))` prints nothing, round 29)
|
|
705
|
+
tokens = [t for t in re.split(r"[\s,()=]+", re.sub(r"\([^()]*\)", "", m.group(1))) if t] if m else []
|
|
706
|
+
if m:
|
|
707
|
+
# with two or more parameters the second parameter's name is echoed (oracle,
|
|
708
|
+
# CR300, 2026-10-10: `(a As Long!, rst As Long)` -> `rst`, not `As`)
|
|
709
|
+
parts, depth, cur = [], 0, ""
|
|
710
|
+
for ch in m.group(1):
|
|
711
|
+
depth += (ch == "(") - (ch == ")")
|
|
712
|
+
if depth < 0:
|
|
713
|
+
break
|
|
714
|
+
if ch == "," and depth == 0:
|
|
715
|
+
parts.append(cur)
|
|
716
|
+
cur = ""
|
|
717
|
+
else:
|
|
718
|
+
cur += ch
|
|
719
|
+
parts.append(cur)
|
|
720
|
+
second = re.match(r"[\s]*([^\s,()=]+)", parts[1]) if len(parts) > 1 else None
|
|
721
|
+
if second:
|
|
722
|
+
tokens = [tokens[0] if tokens else "", second.group(1)]
|
|
723
|
+
elif len(parts) == 1 and not parts[0].strip():
|
|
724
|
+
tokens = [] # `F( ) As Long`: no parameters, nothing to echo
|
|
725
|
+
if len(tokens) >= 2:
|
|
726
|
+
diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
|
|
727
|
+
diags.error(stmt.line, f"Too many parameters: {tokens[1]}.", stmt.file)
|
|
728
|
+
continue
|
|
729
|
+
if isinstance(stmt, S.Unparsed) and (taken := _tainted_prose_instruction(stmt, profile)) is not None:
|
|
730
|
+
# an instruction written as prose, after the cascade started: a deferred-text first
|
|
731
|
+
# parameter loses its shortfall line, a name token is followed by the surplus second
|
|
732
|
+
# parameter (oracle, CR300/CR350, 2026-10-11: `Histogram instruction.`, `CallTable
|
|
733
|
+
# instruction is used to ...`)
|
|
734
|
+
diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
|
|
735
|
+
if taken:
|
|
736
|
+
at = next(
|
|
737
|
+
(i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line),
|
|
738
|
+
len(diags.items),
|
|
739
|
+
)
|
|
740
|
+
diags.items.insert(at, Diagnostic("error", stmt.line, f"Too many parameters: {taken}.", stmt.file))
|
|
741
|
+
continue
|
|
742
|
+
if isinstance(stmt, S.Unparsed) and re.fullmatch(
|
|
743
|
+
r"(?i)\s*(?:do|loop)\s+(?:while|until)\s*", stmt.text or ""
|
|
744
|
+
):
|
|
745
|
+
# (`Do While` with nothing after it: the incomplete-expression line is gone)
|
|
746
|
+
diags.items[:] = [
|
|
747
|
+
d
|
|
748
|
+
for d in diags.items
|
|
749
|
+
if (d.line, d.file) != key or not d.message.startswith("Incomplete expression:")
|
|
750
|
+
]
|
|
751
|
+
continue
|
|
752
|
+
m = (
|
|
753
|
+
re.search(r"(?i)\b(?:While|Until)\b\s*(.*)$", stmt.text or "")
|
|
754
|
+
if isinstance(stmt, S.DoLoop) and stmt.cond is not None
|
|
755
|
+
else re.match(r"(?i)\s*(?:do|loop)\s+(?:while|until)\b\s*(.*\S.*)$", stmt.text or "")
|
|
756
|
+
if isinstance(stmt, S.Unparsed)
|
|
757
|
+
else None
|
|
758
|
+
) # (`Do Until X >`: unparsable too)
|
|
759
|
+
if m:
|
|
760
|
+
# a `Do`/`Loop` condition is rewritten like a second argument (oracle, CR300,
|
|
761
|
+
# 2026-10-04: `Loop Until X=-1` -> `Too many parameters: X.`)
|
|
762
|
+
text = instr_rules._next_simple_token(m.group(1)) or m.group(1)
|
|
763
|
+
diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
|
|
764
|
+
at = next(
|
|
765
|
+
(i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line),
|
|
766
|
+
len(diags.items),
|
|
767
|
+
)
|
|
768
|
+
diags.items.insert(at, Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file))
|
|
769
|
+
rewritten.add(id(stmt))
|
|
770
|
+
continue
|
|
771
|
+
trigger_short = (
|
|
772
|
+
taint_at < index
|
|
773
|
+
and (program.statements[taint_at].line, program.statements[taint_at].file) == key
|
|
774
|
+
and _is_short_instruction(program.statements[taint_at], profile)
|
|
775
|
+
)
|
|
776
|
+
kept_shortfall = (
|
|
777
|
+
[d for d in diags.items if (d.line, d.file) == key and d.message in _SHORTFALL_MESSAGES][:1]
|
|
778
|
+
if trigger_short
|
|
779
|
+
else []
|
|
780
|
+
)
|
|
781
|
+
if (
|
|
782
|
+
isinstance(stmt, S.Instruction)
|
|
783
|
+
and instr_rules.given_count(stmt, profile) == 1
|
|
784
|
+
and _is_short_instruction(stmt, profile)
|
|
785
|
+
):
|
|
786
|
+
if id(stmt) in inline_bodies:
|
|
787
|
+
continue # (a one-line `If` body keeps its shortfall, oracle 2026-10-12: `I = Nope` then `If X Then Delay`)
|
|
788
|
+
# a short instruction after the cascade started has no second argument to echo and
|
|
789
|
+
# its own shortfall disappears (oracle, CR300, 2026-10-10: `Delay(1)` then
|
|
790
|
+
# `PanelTemp(X)` prints one line; on a shared line the trigger's own line stays)
|
|
791
|
+
diags.items[:] = [
|
|
792
|
+
d
|
|
793
|
+
for d in diags.items
|
|
794
|
+
if (d.line, d.file) != key
|
|
795
|
+
or d.message not in _SHORTFALL_MESSAGES
|
|
796
|
+
or any(d is k for k in kept_shortfall)
|
|
797
|
+
]
|
|
798
|
+
continue
|
|
799
|
+
if (
|
|
800
|
+
isinstance(stmt, S.Instruction)
|
|
801
|
+
and len(stmt.args) >= 2
|
|
802
|
+
and stmt.args[1] is not None
|
|
803
|
+
and stmt.keyword.upper() not in ("UNITS", "READONLY", "ALIAS", "CONSTTABLE", "SNMPVARIABLE")
|
|
804
|
+
):
|
|
805
|
+
# (`Units X = m` is never rewritten: repository libraries, oracle; nor is an
|
|
806
|
+
# `Alias` or a `ConstTable (T,0)`, oracle 2026-10-03.)
|
|
807
|
+
# the next simple token, like a genuine overflow (oracle: `MainScanSecs * 4` ->
|
|
808
|
+
# `MainScanSecs`, `SearchString()` -> `SearchString`)
|
|
809
|
+
text = instr_rules._next_simple_token(expressions._arg_text(stmt, 1)) or expressions._arg_text(stmt, 1)
|
|
810
|
+
diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
|
|
811
|
+
at = next(
|
|
812
|
+
(i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line),
|
|
813
|
+
len(diags.items),
|
|
814
|
+
)
|
|
815
|
+
diags.items[at:at] = kept_shortfall
|
|
816
|
+
diags.items.insert(
|
|
817
|
+
at + len(kept_shortfall),
|
|
818
|
+
Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file),
|
|
819
|
+
)
|
|
820
|
+
rewritten.add(id(stmt))
|
|
821
|
+
continue
|
|
822
|
+
if any(_is_cascade_trigger(d) for d in existing):
|
|
823
|
+
tainted = True
|
|
824
|
+
taint_at = index
|
|
825
|
+
for (line, file), held in stash.items(): # condition diagnostics go back before the body's
|
|
826
|
+
at = next((i for i, d in enumerate(diags.items) if d.file == file and d.line >= line), len(diags.items))
|
|
827
|
+
diags.items[at:at] = held
|
|
828
|
+
diags.cascade_gone = dropped_names - registered_names # (names the cascade left unregistered)
|
|
829
|
+
if rewritten:
|
|
830
|
+
_report_rewritten_first_arg(program, profile, diags, dropped, rewritten)
|
|
831
|
+
if dropped or tainted_subs:
|
|
832
|
+
_report_unregistered(program, profile, diags, taint_at, dropped, tainted_subs, rewritten)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
_FLOAT_DEST = 8 # `variable_or_array` "Dest" parameter (`docs/type-codes.md`)
|
|
836
|
+
_DEST_MESSAGE = "Illegal destination variable type, expecting Float."
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
_variable_types = symbols.declared_variable_types
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _destination_types(program: S.Program) -> tuple[dict[str, str], dict[int, dict[str, str]]]:
|
|
843
|
+
"""`declared_variable_types` plus each alias name with its declared source's type, so a Long,
|
|
844
|
+
Boolean or String alias is no Float destination (round 31). A chain `Alias Z=Y` on an alias
|
|
845
|
+
`Z` fails, so only a declared variable passes its type on. An alias inside a routine is that
|
|
846
|
+
routine's only: the second result maps `id(statement)` to its routine's alias types (oracle,
|
|
847
|
+
CR300/CR350, 2026-10-24, `tests/fixtures/r31_review_fixes.json` f1)."""
|
|
848
|
+
types = _variable_types(program)
|
|
849
|
+
declared = {v.name.upper() for st in program.statements if isinstance(st, S.Declaration) for v in st.vars}
|
|
850
|
+
scoped: dict[int, dict[str, str]] = {}
|
|
851
|
+
local: dict[str, str] | None = None
|
|
852
|
+
for st in program.statements:
|
|
853
|
+
if isinstance(st, S.SubDef):
|
|
854
|
+
local = {}
|
|
855
|
+
elif S.ends_routine(st):
|
|
856
|
+
local = None
|
|
857
|
+
if local is not None:
|
|
858
|
+
scoped[id(st)] = local
|
|
859
|
+
if (
|
|
860
|
+
isinstance(st, S.Instruction)
|
|
861
|
+
and st.keyword.upper() == "ALIAS"
|
|
862
|
+
and len(st.args) > 1
|
|
863
|
+
and isinstance(st.args[0], (S.Name, S.Index))
|
|
864
|
+
and st.args[0].name.upper() in types
|
|
865
|
+
):
|
|
866
|
+
for alias in st.args[1:]:
|
|
867
|
+
if isinstance(alias, (S.Name, S.Index)) and alias.name.upper() not in declared:
|
|
868
|
+
(types if local is None else local).setdefault(alias.name.upper(), types[st.args[0].name.upper()])
|
|
869
|
+
return types, scoped
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _is_bad_destination(expr: S.Expr, types: dict[str, str]) -> bool | None:
|
|
873
|
+
"""True when a defined first argument is not a Float variable, None when unknown."""
|
|
874
|
+
if isinstance(expr, (S.Name, S.Index)):
|
|
875
|
+
kind = types.get(expr.name.upper())
|
|
876
|
+
return None if kind is None else kind != "FLOAT"
|
|
877
|
+
return isinstance(expr, (S.Number, S.String, S.Binary, S.Unary))
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def check_float_destinations(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
881
|
+
"""CR300/CR350: a code-8 first argument that fails ends the argument list, so the next
|
|
882
|
+
argument is reported as `Too many parameters` and nothing else on the statement is: an
|
|
883
|
+
undefined name keeps its `is not defined.` line, any other non-Float destination (a Long or
|
|
884
|
+
String variable, a literal, an expression) gives `Illegal destination variable type,
|
|
885
|
+
expecting Float.` (oracle, CR300, 2026-10-01: `PanelTemp(L,60)`, `PanelTemp(Nope,60)`,
|
|
886
|
+
`VoltSe(L,1,mV1000,...)`, `Battery(L)`)."""
|
|
887
|
+
if profile.name not in ("cr300", "cr350"):
|
|
888
|
+
return
|
|
889
|
+
types: dict[str, str] | None = None
|
|
890
|
+
scoped: dict[int, dict[str, str]] = {}
|
|
891
|
+
known = ds = None
|
|
892
|
+
params: dict[str, str] = {} # the enclosing routine's parameters hide a global (round 31)
|
|
893
|
+
per_line = collections.Counter((st.line, st.file) for st in program.statements)
|
|
894
|
+
for stmt in program.statements:
|
|
895
|
+
if isinstance(stmt, S.SubDef):
|
|
896
|
+
params = {p.name.upper(): (p.type or "FLOAT").upper() for p in stmt.params}
|
|
897
|
+
elif S.ends_routine(stmt):
|
|
898
|
+
params = {}
|
|
899
|
+
if (
|
|
900
|
+
not isinstance(stmt, S.Instruction)
|
|
901
|
+
or not stmt.args
|
|
902
|
+
or stmt.args[0] is None
|
|
903
|
+
or per_line[(stmt.line, stmt.file)] > 1
|
|
904
|
+
):
|
|
905
|
+
continue
|
|
906
|
+
info = profile.instructions.get(stmt.keyword.upper())
|
|
907
|
+
literal = (
|
|
908
|
+
next(
|
|
909
|
+
(
|
|
910
|
+
i
|
|
911
|
+
for i, (code, a) in enumerate(zip(info["params"], stmt.args))
|
|
912
|
+
if code in (27, 29) and a is not None and not isinstance(a, S.String) and not _expr_names(a)
|
|
913
|
+
),
|
|
914
|
+
None,
|
|
915
|
+
)
|
|
916
|
+
if info and info["params"]
|
|
917
|
+
else None
|
|
918
|
+
)
|
|
919
|
+
if (
|
|
920
|
+
literal is not None
|
|
921
|
+
and per_line[(stmt.line, stmt.file)] == 1
|
|
922
|
+
and not any((d.line, d.file) == (stmt.line, stmt.file) for d in diags.errors)
|
|
923
|
+
):
|
|
924
|
+
# `RealTime(5)`, `Erase(5)`, `Matrix(5,(1+2),0,R)` -> `Invalid Variable or Array:
|
|
925
|
+
# 5.`, then the argument list ends (oracle, CR300, 2026-10-01: `Move(5,1,X,1)` ->
|
|
926
|
+
# `Too many parameters: 1.`)
|
|
927
|
+
at = next(
|
|
928
|
+
(i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line), len(diags.items)
|
|
929
|
+
)
|
|
930
|
+
new = [
|
|
931
|
+
Diagnostic(
|
|
932
|
+
"error",
|
|
933
|
+
stmt.line,
|
|
934
|
+
f"Invalid Variable or Array: {expressions._arg_text(stmt, literal).strip()}.",
|
|
935
|
+
stmt.file,
|
|
936
|
+
)
|
|
937
|
+
]
|
|
938
|
+
if len(stmt.args) > literal + 1 and stmt.args[literal + 1] is not None:
|
|
939
|
+
text = instr_rules._next_simple_token(
|
|
940
|
+
expressions._arg_text(stmt, literal + 1)
|
|
941
|
+
) or expressions._arg_text(stmt, literal + 1)
|
|
942
|
+
new.append(Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file))
|
|
943
|
+
diags.items[at:at] = new
|
|
944
|
+
continue
|
|
945
|
+
if not info or not info["params"] or info["params"][0] != _FLOAT_DEST:
|
|
946
|
+
continue
|
|
947
|
+
if types is None:
|
|
948
|
+
(types, scoped), known = _destination_types(program), expressions._known_names(program, profile)
|
|
949
|
+
ds = expressions.program_dims(program, profile, known)
|
|
950
|
+
assert known is not None and ds is not None
|
|
951
|
+
arg = stmt.args[0]
|
|
952
|
+
if isinstance(arg, S.Name) and arg.name.upper() in profile.functions:
|
|
953
|
+
continue # a bare function name is `Expression expects (` instead (expressions.py)
|
|
954
|
+
key = (stmt.line, stmt.file)
|
|
955
|
+
undefined = [n for n in _expr_names(arg) if n.upper() not in known]
|
|
956
|
+
expression = False
|
|
957
|
+
dims = False
|
|
958
|
+
if undefined:
|
|
959
|
+
if isinstance(arg, (S.Binary, S.Unary)):
|
|
960
|
+
# an arithmetic expression is no destination: after its names, `Dimensions not
|
|
961
|
+
# declared` or the destination-type error (oracle, CR300, 2026-10-03)
|
|
962
|
+
expression = True
|
|
963
|
+
undefined, dims = expressions._names_and_dims(known, ds, stmt, (), arg)
|
|
964
|
+
elif not isinstance(arg, (S.Name, S.Index)):
|
|
965
|
+
continue
|
|
966
|
+
keep = [profile.unresolved_symbol_message.format(name=n) for n in undefined]
|
|
967
|
+
elif _is_bad_destination(arg, {**types, **scoped.get(id(stmt), {}), **params}):
|
|
968
|
+
keep = []
|
|
969
|
+
else:
|
|
970
|
+
continue
|
|
971
|
+
own = [d for d in diags.items if (d.line, d.file) == key and d.severity == "error"]
|
|
972
|
+
if not keep and any(d.message.startswith("Dimensions not declared") for d in own):
|
|
973
|
+
continue # (`Battery(L(2))` for a Long scalar: over-indexing is reported first)
|
|
974
|
+
if keep and [d.message for d in own[: len(keep)]] != keep:
|
|
975
|
+
continue
|
|
976
|
+
at = (
|
|
977
|
+
diags.items.index(own[0])
|
|
978
|
+
if own
|
|
979
|
+
else next(
|
|
980
|
+
(i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line), len(diags.items)
|
|
981
|
+
)
|
|
982
|
+
)
|
|
983
|
+
new = list(own[: len(keep)]) or [Diagnostic("error", stmt.line, _DEST_MESSAGE, stmt.file)]
|
|
984
|
+
if keep and (dims if expression else expressions._has_undefined_index(arg, known, ds, stmt)):
|
|
985
|
+
# (oracle, CR300, 2026-10-03: `Battery(Nope(1))`, `PanelTemp(Nope(1),15000)`)
|
|
986
|
+
new.append(
|
|
987
|
+
Diagnostic(
|
|
988
|
+
"error",
|
|
989
|
+
stmt.line,
|
|
990
|
+
expressions.dimensions_message(expressions._arg_text(stmt, 0).strip(), profile, False),
|
|
991
|
+
stmt.file,
|
|
992
|
+
)
|
|
993
|
+
)
|
|
994
|
+
elif keep and expression:
|
|
995
|
+
new.append(Diagnostic("error", stmt.line, _DEST_MESSAGE, stmt.file))
|
|
996
|
+
if len(stmt.args) >= 2 and stmt.args[1] is not None:
|
|
997
|
+
text = instr_rules._next_simple_token(expressions._arg_text(stmt, 1)) or expressions._arg_text(stmt, 1)
|
|
998
|
+
new.append(Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file))
|
|
999
|
+
diags.items[:] = (
|
|
1000
|
+
[d for d in diags.items[:at] if (d.line, d.file) != key or d.severity != "error"]
|
|
1001
|
+
+ new
|
|
1002
|
+
+ [d for d in diags.items[at:] if (d.line, d.file) != key or d.severity != "error"]
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
# Parameter codes resolved as a runtime expression or variable: an undefined name there ends the
|
|
1007
|
+
# argument list on CR300/CR350 (`docs/type-codes.md`; code 1 constants report `Variable illegal`).
|
|
1008
|
+
_ABORTING_PARAMS = frozenset({2, 8, 24, 25, 26, 27, 28, 29, 33, 40, 41, 47})
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def drop_too_many_after_variable_illegal(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1012
|
+
"""CR300/CR350: `Variable illegal in Parameter` ends the argument list, so the statement's
|
|
1013
|
+
genuine `Too many parameters` is not reached (oracle, 2026-10-01: `PortSet(C4,1,1)`)."""
|
|
1014
|
+
if profile.name not in ("cr300", "cr350"):
|
|
1015
|
+
return
|
|
1016
|
+
per_line = collections.Counter((st.line, st.file) for st in program.statements)
|
|
1017
|
+
# (a routine header too: `Sub S(P(K),Q,R)` -> `... R.`, oracle, 2026-10-22, round 29)
|
|
1018
|
+
declarations = {(st.line, st.file) for st in program.statements if isinstance(st, (S.Declaration, S.SubDef))}
|
|
1019
|
+
bad = {
|
|
1020
|
+
(d.line, d.file)
|
|
1021
|
+
for d in diags.errors
|
|
1022
|
+
if d.message.startswith(_VARIABLE_ILLEGAL_PREFIX)
|
|
1023
|
+
and per_line[(d.line, d.file)] == 1
|
|
1024
|
+
and (d.line, d.file) not in declarations
|
|
1025
|
+
} # (a declaration names the variable two on)
|
|
1026
|
+
if bad:
|
|
1027
|
+
diags.items[:] = [
|
|
1028
|
+
d for d in diags.items if not ((d.line, d.file) in bad and d.message.startswith(_TOO_MANY_PREFIX))
|
|
1029
|
+
]
|
|
1030
|
+
# The excess argument is reached after the earlier ones' names: `Battery(foo, bar)` ->
|
|
1031
|
+
# `foo is not defined.`, then `Too many parameters: bar.` (oracle, CR300, 2026-10-01).
|
|
1032
|
+
items = diags.items
|
|
1033
|
+
for i in range(len(items) - 1, -1, -1):
|
|
1034
|
+
d = items[i]
|
|
1035
|
+
if d.severity != "error" or not d.message.startswith(_TOO_MANY_PREFIX) or per_line[(d.line, d.file)] != 1:
|
|
1036
|
+
continue
|
|
1037
|
+
later = [
|
|
1038
|
+
j
|
|
1039
|
+
for j in range(i + 1, len(items))
|
|
1040
|
+
if (items[j].line, items[j].file) == (d.line, d.file) and items[j].message.endswith(_UNRESOLVED_SUFFIX)
|
|
1041
|
+
]
|
|
1042
|
+
if later:
|
|
1043
|
+
items.insert(later[-1], items.pop(i)) # (`later` indices shift by one after the pop)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def check_undefined_arg_aborts(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1047
|
+
"""CR300/CR350: the first argument with an undefined name, at a runtime-expression parameter,
|
|
1048
|
+
ends the argument list: its `is not defined.` lines are followed by `Too many parameters:
|
|
1049
|
+
<next argument>.` and nothing else on the statement is reported (oracle, CR300, 2026-10-01:
|
|
1050
|
+
`Delay(0,Nope,Sec)` -> `Too many parameters: Sec.`, `Move(A(1),2,Nope,1)` -> `... 1.`)."""
|
|
1051
|
+
if profile.name not in ("cr300", "cr350"):
|
|
1052
|
+
return
|
|
1053
|
+
known = None
|
|
1054
|
+
per_line = collections.Counter((st.line, st.file) for st in program.statements)
|
|
1055
|
+
for stmt in program.statements:
|
|
1056
|
+
if not isinstance(stmt, S.Instruction) or per_line[(stmt.line, stmt.file)] > 1:
|
|
1057
|
+
continue
|
|
1058
|
+
info = profile.instructions.get(stmt.keyword.upper())
|
|
1059
|
+
if not info or not info["params"]:
|
|
1060
|
+
continue
|
|
1061
|
+
if known is None:
|
|
1062
|
+
known = expressions._known_names(program, profile)
|
|
1063
|
+
for index, arg in enumerate(stmt.args):
|
|
1064
|
+
names = [n for n in _expr_names(arg) if n.upper() not in known]
|
|
1065
|
+
if not names:
|
|
1066
|
+
continue
|
|
1067
|
+
code = info["params"][index] if index < len(info["params"]) else None
|
|
1068
|
+
if (
|
|
1069
|
+
code not in _ABORTING_PARAMS
|
|
1070
|
+
or (index == 0 and code == _FLOAT_DEST)
|
|
1071
|
+
or index + 1 >= len(stmt.args)
|
|
1072
|
+
or stmt.args[index + 1] is None
|
|
1073
|
+
):
|
|
1074
|
+
break
|
|
1075
|
+
key = (stmt.line, stmt.file)
|
|
1076
|
+
wanted = {profile.unresolved_symbol_message.format(name=n) for n in names}
|
|
1077
|
+
own = [d for d in diags.items if (d.line, d.file) == key and d.severity == "error"]
|
|
1078
|
+
keep = [d for d in own if d.message in wanted]
|
|
1079
|
+
if not keep or any(d.message.startswith("Dimensions not declared") for d in own):
|
|
1080
|
+
break # (`expressions._report_statement` already ended an undefined `A(i)`)
|
|
1081
|
+
too_many = [d for d in own if d.message.startswith(_TOO_MANY_PREFIX)]
|
|
1082
|
+
if too_many:
|
|
1083
|
+
# a genuine overflow found earlier still follows the names (`SW12(SW12_1,1)`)
|
|
1084
|
+
rest = [d for d in diags.items if d not in keep]
|
|
1085
|
+
at = rest.index(too_many[0])
|
|
1086
|
+
followup = []
|
|
1087
|
+
if stmt.keyword.upper() == "SERIALIN" and index == 0 and isinstance(stmt.args[0], S.Name):
|
|
1088
|
+
followup.append(Diagnostic("error", stmt.line, instr_rules._SERIALIN_DEST_MESSAGE, stmt.file))
|
|
1089
|
+
diags.items[:] = rest[:at] + keep + followup + rest[at:]
|
|
1090
|
+
break
|
|
1091
|
+
text = instr_rules._next_simple_token(expressions._arg_text(stmt, index + 1)) or expressions._arg_text(
|
|
1092
|
+
stmt, index + 1
|
|
1093
|
+
)
|
|
1094
|
+
at = diags.items.index(keep[-1]) + 1
|
|
1095
|
+
if stmt.keyword.upper() == "SERIALIN" and index == 0 and isinstance(stmt.args[0], S.Name):
|
|
1096
|
+
diags.items.insert(at, Diagnostic("error", stmt.line, instr_rules._SERIALIN_DEST_MESSAGE, stmt.file))
|
|
1097
|
+
at += 1
|
|
1098
|
+
diags.items.insert(at, Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file))
|
|
1099
|
+
drop = {id(d) for d in own if d not in keep}
|
|
1100
|
+
diags.items[:] = [d for d in diags.items if id(d) not in drop]
|
|
1101
|
+
break
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _expr_names(expr: S.Expr | None) -> list[str]:
|
|
1105
|
+
if isinstance(expr, S.Name):
|
|
1106
|
+
return [expr.name]
|
|
1107
|
+
if isinstance(expr, S.Index):
|
|
1108
|
+
return [expr.name] + [n for a in expr.args for n in _expr_names(a)]
|
|
1109
|
+
if isinstance(expr, S.Binary):
|
|
1110
|
+
return _expr_names(expr.left) + _expr_names(expr.right)
|
|
1111
|
+
if isinstance(expr, S.Unary):
|
|
1112
|
+
return _expr_names(expr.operand)
|
|
1113
|
+
return []
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
# Parameter type codes whose argument is resolved as an expression or variable
|
|
1117
|
+
# (`docs/type-codes.md`); identifiers, declarations and deferred text are not.
|
|
1118
|
+
_RESOLVED_FIRST_PARAM = frozenset({1, 2, 5, 7, 8, 24, 25, 27, 28, 29, 33, 38, 39, 40, 41, 47})
|
|
1119
|
+
_CONST_FIRST_PARAM = frozenset({1, 5, 38, 39}) # constant-only (`docs/type-codes.md`)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _report_rewritten_first_arg(
|
|
1123
|
+
program: S.Program, profile, diags: Diagnostics, dropped: set[int], rewritten: set[int]
|
|
1124
|
+
) -> None:
|
|
1125
|
+
"""A rewritten instruction's first argument is still checked before its `Too many
|
|
1126
|
+
parameters` line: each undefined name (unregistered ones included), then `Dimensions not
|
|
1127
|
+
declared: A(I).` for an undefined indexed name (oracle, CR300, 2026-10-01: `PanelTemp(PTemp,
|
|
1128
|
+
15000)` after `Dim Z banana` then `Public PTemp`)."""
|
|
1129
|
+
import dataclasses
|
|
1130
|
+
|
|
1131
|
+
known = expressions._known_names(
|
|
1132
|
+
dataclasses.replace(program, statements=[st for st in program.statements if id(st) not in dropped]), profile
|
|
1133
|
+
)
|
|
1134
|
+
tables = getattr(known, "tables", ())
|
|
1135
|
+
types, scoped = _destination_types(program)
|
|
1136
|
+
|
|
1137
|
+
def undefined(name: str) -> bool:
|
|
1138
|
+
return name.upper() not in known or name.upper() in tables
|
|
1139
|
+
|
|
1140
|
+
for stmt in program.statements:
|
|
1141
|
+
if not isinstance(stmt, S.Instruction) or id(stmt) not in rewritten or not stmt.args or stmt.args[0] is None:
|
|
1142
|
+
continue
|
|
1143
|
+
info = profile.instructions.get(stmt.keyword.upper())
|
|
1144
|
+
if not info or not info["params"] or info["params"][0] not in _RESOLVED_FIRST_PARAM:
|
|
1145
|
+
continue
|
|
1146
|
+
messages: list[str] = []
|
|
1147
|
+
|
|
1148
|
+
def walk(expr: S.Expr | None) -> None:
|
|
1149
|
+
if isinstance(expr, S.Name):
|
|
1150
|
+
if undefined(expr.name):
|
|
1151
|
+
messages.append(profile.unresolved_symbol_message.format(name=expr.name))
|
|
1152
|
+
elif isinstance(expr, S.Index):
|
|
1153
|
+
bad = undefined(expr.name)
|
|
1154
|
+
if bad:
|
|
1155
|
+
messages.append(profile.unresolved_symbol_message.format(name=expr.name))
|
|
1156
|
+
for a in expr.args:
|
|
1157
|
+
walk(a)
|
|
1158
|
+
if bad and any(a is not None for a in expr.args):
|
|
1159
|
+
messages.append(f"Dimensions not declared: {expressions._arg_text(stmt, 0)}.")
|
|
1160
|
+
elif isinstance(expr, S.Binary):
|
|
1161
|
+
walk(expr.left)
|
|
1162
|
+
walk(expr.right)
|
|
1163
|
+
elif isinstance(expr, S.Unary):
|
|
1164
|
+
walk(expr.operand)
|
|
1165
|
+
|
|
1166
|
+
walk(stmt.args[0])
|
|
1167
|
+
if messages and info["params"][0] in _CONST_FIRST_PARAM:
|
|
1168
|
+
# a constant-only parameter ends the list: `PortSet(C4,1)` -> `C4 is not defined.`,
|
|
1169
|
+
# `Variable illegal in Parameter: C4.`, no `Too many` (oracle, CR300, 2026-10-01)
|
|
1170
|
+
messages = [m for m in messages if m.endswith(" is not defined.")]
|
|
1171
|
+
messages.append(f"Variable illegal in Parameter: {expressions._arg_text(stmt, 0).strip()}.")
|
|
1172
|
+
diags.items[:] = [
|
|
1173
|
+
d
|
|
1174
|
+
for d in diags.items
|
|
1175
|
+
if not ((d.line, d.file) == (stmt.line, stmt.file) and d.message.startswith(_TOO_MANY_PREFIX))
|
|
1176
|
+
]
|
|
1177
|
+
at = next(
|
|
1178
|
+
(i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line), len(diags.items)
|
|
1179
|
+
)
|
|
1180
|
+
diags.items[at:at] = [Diagnostic("error", stmt.line, m, stmt.file, cascade=False) for m in messages]
|
|
1181
|
+
continue
|
|
1182
|
+
if info["params"][0] == _FLOAT_DEST:
|
|
1183
|
+
arg = stmt.args[0]
|
|
1184
|
+
names_ok = not messages
|
|
1185
|
+
if not isinstance(arg, (S.Name, S.Index)) or (
|
|
1186
|
+
names_ok and _is_bad_destination(arg, {**types, **scoped.get(id(stmt), {})})
|
|
1187
|
+
):
|
|
1188
|
+
messages.append(_DEST_MESSAGE)
|
|
1189
|
+
if stmt.keyword.upper() == "SERIALIN" and (
|
|
1190
|
+
instr_rules._serialin_bad_destination(stmt.args[0], instr_rules._serialin_types(program, profile))
|
|
1191
|
+
or (messages and isinstance(stmt.args[0], S.Name))
|
|
1192
|
+
):
|
|
1193
|
+
messages.append(instr_rules._SERIALIN_DEST_MESSAGE)
|
|
1194
|
+
if not messages:
|
|
1195
|
+
continue
|
|
1196
|
+
at = next(
|
|
1197
|
+
i
|
|
1198
|
+
for i, d in enumerate(diags.items)
|
|
1199
|
+
if (d.line, d.file) == (stmt.line, stmt.file) and d.message.startswith(_TOO_MANY_PREFIX)
|
|
1200
|
+
)
|
|
1201
|
+
diags.items[at:at] = [Diagnostic("error", stmt.line, m, stmt.file, cascade=False) for m in messages]
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _report_unregistered(
|
|
1205
|
+
program: S.Program,
|
|
1206
|
+
profile,
|
|
1207
|
+
diags: Diagnostics,
|
|
1208
|
+
taint_at: int,
|
|
1209
|
+
dropped: set[int],
|
|
1210
|
+
tainted_subs: set[int],
|
|
1211
|
+
rewritten: set[int],
|
|
1212
|
+
) -> None:
|
|
1213
|
+
"""Names a tainted declaration would have registered are undefined afterwards: `Public A`
|
|
1214
|
+
after `Dim Z banana` makes a later `X = A` print `A is not defined.`, and a tainted `Function
|
|
1215
|
+
F (x)` loses its parameters (`F(1)` -> `No Matching, or out of place ) ...`; `X = a` in a
|
|
1216
|
+
tainted `Sub S (a)` -> `a is not defined.`). A `Const` is still registered (oracle, CR300,
|
|
1217
|
+
2026-09-28, `docs/loggers.md` "Tainted declarations"). The tier-B name checks are re-run on
|
|
1218
|
+
a copy of the program without those declarations, keeping only their findings on statements
|
|
1219
|
+
after the taint that use an unregistered name or a tainted `Function`."""
|
|
1220
|
+
import dataclasses
|
|
1221
|
+
|
|
1222
|
+
from .. import consteval
|
|
1223
|
+
from . import structure, unused
|
|
1224
|
+
|
|
1225
|
+
statements = []
|
|
1226
|
+
for st in program.statements:
|
|
1227
|
+
if id(st) in dropped:
|
|
1228
|
+
continue
|
|
1229
|
+
statements.append(
|
|
1230
|
+
dataclasses.replace(st, params=[]) if isinstance(st, S.SubDef) and id(st) in tainted_subs else st
|
|
1231
|
+
)
|
|
1232
|
+
modified = dataclasses.replace(program, statements=statements)
|
|
1233
|
+
unregistered = expressions._known_names(program, profile) - expressions._known_names(modified, profile)
|
|
1234
|
+
functions = {
|
|
1235
|
+
st.name.upper()
|
|
1236
|
+
for st in program.statements
|
|
1237
|
+
if isinstance(st, S.SubDef) and id(st) in tainted_subs and st.keyword == "FUNCTION"
|
|
1238
|
+
}
|
|
1239
|
+
affected: set[tuple[int, str]] = set()
|
|
1240
|
+
for st in program.statements[taint_at + 1 :]:
|
|
1241
|
+
if id(st) in dropped or id(st) in rewritten or isinstance(st, S.SubDef):
|
|
1242
|
+
continue
|
|
1243
|
+
names: set[str] = set()
|
|
1244
|
+
unused._walk_stmt(st, names)
|
|
1245
|
+
if names & unregistered or (not isinstance(st, S.Call) and names & functions):
|
|
1246
|
+
affected.add((st.line, st.file))
|
|
1247
|
+
if not affected:
|
|
1248
|
+
return
|
|
1249
|
+
scratch = Diagnostics()
|
|
1250
|
+
env = consteval.Env(profile, dict(program.consts))
|
|
1251
|
+
structure.check_pass2(modified, profile, scratch, env)
|
|
1252
|
+
expressions.check(modified, profile, scratch, env)
|
|
1253
|
+
expressions.check_function_call_counts(modified, profile, scratch)
|
|
1254
|
+
existing = collections.Counter((e.line, e.file, e.message) for e in diags.items)
|
|
1255
|
+
for key in sorted(affected, key=lambda k: (k[1] or "", k[0])):
|
|
1256
|
+
fresh = [d for d in scratch.items if (d.line, d.file) == key and d.severity == "error"]
|
|
1257
|
+
at = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
|
|
1258
|
+
old = collections.Counter(diags.items[i].message for i in at)
|
|
1259
|
+
if (
|
|
1260
|
+
at
|
|
1261
|
+
and at == list(range(at[0], at[-1] + 1))
|
|
1262
|
+
and all(diags.items[i].severity == "error" for i in at)
|
|
1263
|
+
and not old - collections.Counter(d.message for d in fresh)
|
|
1264
|
+
):
|
|
1265
|
+
# the names are reported in text order: a newly undefined name (`For i = 1 To nv`
|
|
1266
|
+
# with `i` dropped) goes before an already reported one (oracle, CR300, 2026-10-04)
|
|
1267
|
+
diags.items[at[0] : at[-1] + 1] = fresh
|
|
1268
|
+
for d in fresh:
|
|
1269
|
+
existing[(d.line, d.file, d.message)] -= 1
|
|
1270
|
+
continue
|
|
1271
|
+
for d in fresh:
|
|
1272
|
+
if existing[(d.line, d.file, d.message)]:
|
|
1273
|
+
existing[(d.line, d.file, d.message)] -= 1
|
|
1274
|
+
else:
|
|
1275
|
+
diags.items.append(d)
|
|
1276
|
+
_respliced_call_args(modified, profile, diags, scratch, affected)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _respliced_call_args(
|
|
1280
|
+
modified: S.Program, profile, diags: Diagnostics, scratch: Diagnostics, affected: set[tuple[int, str]]
|
|
1281
|
+
) -> None:
|
|
1282
|
+
"""An unresolved call's argument names, rechecked without the unregistered declarations
|
|
1283
|
+
(`CDM_Battery(CDM_A108,1,Batt_Volt)` after the taint also reports `Batt_Volt`)."""
|
|
1284
|
+
check_undefined_call_args(modified, profile, scratch)
|
|
1285
|
+
calls = {(st.line, st.file) for st in modified.statements if isinstance(st, S.Call) and not st.explicit}
|
|
1286
|
+
for key in affected & calls:
|
|
1287
|
+
fresh = [d for d in scratch.items if (d.line, d.file) == key and d.severity == "error"]
|
|
1288
|
+
at = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
|
|
1289
|
+
if not at or at != list(range(at[0], at[0] + len(at))) or len(fresh) <= len(at):
|
|
1290
|
+
continue
|
|
1291
|
+
if [d.message for d in diags.items[at[0] : at[-1] + 1]] != [
|
|
1292
|
+
d.message for d in fresh if d.message in {e.message for e in diags.items[at[0] : at[-1] + 1]}
|
|
1293
|
+
]:
|
|
1294
|
+
continue
|
|
1295
|
+
diags.items[at[0] : at[-1] + 1] = fresh
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
# `check_unknown_line_words` (this round): a line that parses as nothing recognisable at all --
|
|
1299
|
+
# every corpus instance found this round is CHM example prose left uncommented, e.g. `To force
|
|
1300
|
+
# double-precision arithmetic, declare ...` -- becomes an `S.Unparsed` statement and hits
|
|
1301
|
+
# `checker/structure.py`'s own generic `Invalid, or out of place expression: %s.` fallback on
|
|
1302
|
+
# every logger. CR300/CR350 additionally print one `%s is not defined.` line (`profile.
|
|
1303
|
+
# unresolved_symbol_message`) per *leading* bare word of that statement's own source text, ahead of
|
|
1304
|
+
# the generic line, following a "resolve one syntactic chain, plus one trailing word" shape --
|
|
1305
|
+
# oracle-confirmed this round (`docs/loggers.md` "Round 8", scratch probes, `t.cr6`, both
|
|
1306
|
+
# `cr300comp.exe`/`cr350comp.exe`, identical): starting at the statement's first word,
|
|
1307
|
+
# 1. A run of bare words joined only by a single connecting *operator* character (any of
|
|
1308
|
+
# `+-*/^&<>=`, immediately adjacent, no surrounding space -- `alpha-beta-gamma delta` flags
|
|
1309
|
+
# `alpha`, `beta`, `gamma`, all three, i.e. the chain is not capped) all get checked, in
|
|
1310
|
+
# order, as if each were a real value reference.
|
|
1311
|
+
# 2. Once that chain stops (the next thing is *not* one more `<op><word>` link), exactly one more
|
|
1312
|
+
# bare word is checked if -- and only if -- it is separated from the chain by plain
|
|
1313
|
+
# whitespace and nothing else (`foo bar baz qux` flags only `foo`, `bar`; `foo, bar` flags
|
|
1314
|
+
# only `foo`, no peek at `bar` -- a comma is not "plain whitespace"; `Part 1.` flags only
|
|
1315
|
+
# `Part` -- the peek must itself be a bare word, not a number). This one word is checked
|
|
1316
|
+
# whether or not it was actually reachable as valid syntax; it, too, only reports if
|
|
1317
|
+
# unresolved.
|
|
1318
|
+
# 3. Whatever is or is not flagged, scanning stops there for good -- nothing further along the
|
|
1319
|
+
# line is ever checked (`foo bar baz` never touches `baz`; `This is not defined test` never
|
|
1320
|
+
# touches `not`/`defined`/`test`, because `is` is one of this project's own extracted
|
|
1321
|
+
# `functions.json` names -- see below -- and already terminates the one-word peek).
|
|
1322
|
+
# "Resolved" here is `expressions._known_names(program, profile)`, the *same* set every other
|
|
1323
|
+
# CR300/CR350 undefined-name check in this project already uses -- confirmed sufficient with no
|
|
1324
|
+
# separate reserved-word list needed, because this project's own extracted per-logger tables
|
|
1325
|
+
# already carry CRBasic's syntax keywords alongside its predefined symbols (`Double`/`Long`/
|
|
1326
|
+
# `String`/`Boolean` in `symbols.json`, `To`/`Is` in `functions.json`, `Public` in
|
|
1327
|
+
# `instructions.json` -- confirmed present for CR300; not re-checked for CR350 individually, but
|
|
1328
|
+
# both loggers' own tables come from the same `extract_tables.py` process and every shape probed
|
|
1329
|
+
# above (`Double foo`, `This is not defined test`, ...) came back byte-identical on both compilers
|
|
1330
|
+
# this round, so the same table shape is assumed, not re-verified name-by-name).
|
|
1331
|
+
#
|
|
1332
|
+
# What this deliberately does *not* attempt, for lack of oracle coverage this round (documented
|
|
1333
|
+
# rather than guessed at, `AGENTS.md`): a connecting operator with surrounding *whitespace*
|
|
1334
|
+
# (`alpha - beta`, untested -- only the no-space `alpha-beta` form was probed); any operator other
|
|
1335
|
+
# than the eight tested characters; and a leading word that is itself the very first token of the
|
|
1336
|
+
# *whole statement* and already resolves (`Double foo` was probed and *does* still flag `foo` --
|
|
1337
|
+
# handled correctly by this algorithm, since resolution is checked only when deciding whether to
|
|
1338
|
+
# *report* a word, never when deciding whether to keep walking -- but no case where the walk
|
|
1339
|
+
# itself needs to treat a resolved word differently from an unresolved one was found, so none is
|
|
1340
|
+
# implemented).
|
|
1341
|
+
_GENERIC_FALLBACK_PREFIX = "Invalid, or out of place expression: "
|
|
1342
|
+
_LINE_WORD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
1343
|
+
# Instructions without parameters: text after the name is a surplus parameter (oracle, CR300 and
|
|
1344
|
+
# CR350, 2026-10-11, one probe each; `EndProg` ignores its tail and `Alias`, `Const`, `Data`, ... read
|
|
1345
|
+
# it themselves, so they are not listed).
|
|
1346
|
+
_TAIL_REJECTING = frozenset(
|
|
1347
|
+
n.upper()
|
|
1348
|
+
for n in (
|
|
1349
|
+
"BeginProg PipeLineMode SequentialMode EndIf WEND OpenInterval NEXTSCAN NEXTSUBSCAN ENDTABLE "
|
|
1350
|
+
"FillStop ExitScan ContinueScan ExitSub ExitDo ExitFor Restore AngleDegrees ShutdownBegin "
|
|
1351
|
+
"ShutdownEnd EndSequence ExitFunction AbortScan SampleFieldCal WaitTriggerSequence "
|
|
1352
|
+
"PulseCountReset WebPageEnd TableHide DHCPRenew Restart PreserveVariables "
|
|
1353
|
+
"ApplyAndRestartSequence EndApplyAndRestartSequence"
|
|
1354
|
+
).split()
|
|
1355
|
+
)
|
|
1356
|
+
_ZERO_TAIL_RE = re.compile(r"\s*([A-Za-z_]\w*)(?=[\s,(])(.*)$", re.S)
|
|
1357
|
+
# A parameter text that starts with a binary operator word: `Invalid, or out of place expression`
|
|
1358
|
+
# after the next operand is named (`is`/`mod` stop at once), not `Incomplete expression`.
|
|
1359
|
+
_LEAD_OP_WORD_RE = re.compile(r"(?i)(is|mod|and|or|xor|imp|eqv|to)(?![A-Za-z0-9_])")
|
|
1360
|
+
_LEAD_OP_NO_OPERAND = frozenset({"IS", "MOD"})
|
|
1361
|
+
_LEAD_OP_SKIP_RE = re.compile(r"(?i)^[ \t]*(?:(?:and|or|xor|imp|eqv|to|not|mod|is)(?![A-Za-z0-9_])[ \t]*)*")
|
|
1362
|
+
_INSTRUCTION_PROSE_RE = re.compile(r"\s*([A-Za-z_][A-Za-z0-9_]*)[ \t]+([A-Za-z_][^(]*)$", re.S)
|
|
1363
|
+
_LINE_OPERAND_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?\b|\"[^\"]*\"")
|
|
1364
|
+
# Keyword operators join the chain like `+` (oracle, CR300, 2026-10-01: `foo and bar baz qux` ->
|
|
1365
|
+
# foo, bar, baz; `Is` and `Not` do not).
|
|
1366
|
+
_LINE_OP_WORD_RE = re.compile(r"[ \t]+(?i:and|or|xor|eqv|imp|mod|to)[ \t]+(?=[A-Za-z_0-9\"])")
|
|
1367
|
+
# A line starting `word.` reads as a table field: `Could not find DataTable word.` (even for a
|
|
1368
|
+
# declared name), and nothing at all once the cascade has started (oracle, CR300, 2026-10-01).
|
|
1369
|
+
_TABLE_LINE_RE = re.compile(r"\s*([A-Za-z_][A-Za-z0-9_]*)\.")
|
|
1370
|
+
# the same with a sign in front (`+ Test.TCTemp(1)`, a wrapped string concatenation, oracle,
|
|
1371
|
+
# CR300/CR350, 2026-10-11): `Could not find DataTable` for an unknown table, nothing after the cascade
|
|
1372
|
+
_SIGNED_TABLE_RE = re.compile(r"\s*[-+][ \t]*([A-Za-z_][A-Za-z0-9_]*)\.(?=[A-Za-z_])")
|
|
1373
|
+
_LINE_OP_RUN_RE = re.compile(r"(?:[ \t]*(?:<>|<=|>=|[-+*/^&<>=])[ \t]*)(?=[A-Za-z_0-9\"])|(?:<>|<=|>=|[-+*/^&<>=])")
|
|
1374
|
+
_LINE_WS_RE = re.compile(r"[ \t]+")
|
|
1375
|
+
_LINE_PAREN_RE = re.compile(r"[ \t]*\(")
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def _split_top_commas(text: str) -> list[str]:
|
|
1379
|
+
parts, depth, cur = [], 0, ""
|
|
1380
|
+
for ch in text:
|
|
1381
|
+
depth += (ch == "(") - (ch == ")")
|
|
1382
|
+
if ch == "," and depth <= 0:
|
|
1383
|
+
parts.append(cur)
|
|
1384
|
+
cur = ""
|
|
1385
|
+
else:
|
|
1386
|
+
cur += ch
|
|
1387
|
+
return parts + [cur]
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _inside_table(program: S.Program, line: int, file: str) -> bool:
|
|
1391
|
+
"""The statement at `line` sits in a `DataTable` body."""
|
|
1392
|
+
inside = False
|
|
1393
|
+
for st in program.statements:
|
|
1394
|
+
kw = st.keyword.upper() if isinstance(st, S.Instruction) else ""
|
|
1395
|
+
if (st.line, st.file) == (line, file):
|
|
1396
|
+
return inside
|
|
1397
|
+
if kw == "DATATABLE":
|
|
1398
|
+
inside = True
|
|
1399
|
+
elif kw == "ENDTABLE":
|
|
1400
|
+
inside = False
|
|
1401
|
+
return False
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def _loose_before_beginprog(program: S.Program, line: int, file: str) -> bool:
|
|
1405
|
+
"""The statement at `line` precedes `BeginProg` outside any Sub/Function/table body."""
|
|
1406
|
+
depth = 0
|
|
1407
|
+
for st in program.statements:
|
|
1408
|
+
kw = st.keyword.upper() if isinstance(st, S.Instruction) else ""
|
|
1409
|
+
if (st.line, st.file) == (line, file):
|
|
1410
|
+
return depth == 0
|
|
1411
|
+
if kw == "BEGINPROG":
|
|
1412
|
+
return False
|
|
1413
|
+
if isinstance(st, S.SubDef) or kw in ("DATATABLE", "CONSTTABLE", "DISPLAYMENU", "STRUCTURETYPE"):
|
|
1414
|
+
depth += 1
|
|
1415
|
+
elif S.ends_routine(st) or kw in ("ENDTABLE", "ENDCONSTTABLE", "ENDMENU", "ENDSTRUCTURETYPE"):
|
|
1416
|
+
depth = max(0, depth - 1)
|
|
1417
|
+
return False
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def prose_known(program: S.Program, profile) -> set[str]:
|
|
1421
|
+
"""Names a prose word resolves to: an instruction keyword that is not also a symbol or function
|
|
1422
|
+
is reported (oracle, CR300, 2026-10-01: `foo Battery` -> `Battery is not defined.`, `foo Sin`
|
|
1423
|
+
and `foo Scan` are not)."""
|
|
1424
|
+
instruction_only = set(profile.instructions) - set(profile.symbols) - set(profile.functions)
|
|
1425
|
+
known = expressions._known_names(program, profile)
|
|
1426
|
+
return set(known) - instruction_only - set(known.tables)
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def prose_known_tables(program: S.Program, profile) -> set[str]:
|
|
1430
|
+
"""Upper-cased user `DataTable` names (a table field line is not an unknown table)."""
|
|
1431
|
+
return set(getattr(expressions._known_names(program, profile), "tables", ()))
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def first_declarations(program: S.Program) -> dict[str, tuple[str, int]]:
|
|
1435
|
+
"""Upper-cased variable/`Const`/`Alias` name -> (file, line) of its first declaration."""
|
|
1436
|
+
first: dict[str, tuple[str, int]] = {}
|
|
1437
|
+
for st in program.statements:
|
|
1438
|
+
names: list[str] = []
|
|
1439
|
+
if isinstance(st, S.Declaration):
|
|
1440
|
+
names = [v.name for v in st.vars]
|
|
1441
|
+
elif isinstance(st, S.ConstDecl):
|
|
1442
|
+
names = [st.name]
|
|
1443
|
+
elif isinstance(st, S.Instruction) and st.keyword.upper() == "ALIAS":
|
|
1444
|
+
names = [a.name for a in st.args[1:] if isinstance(a, S.Name)]
|
|
1445
|
+
for n in names:
|
|
1446
|
+
first.setdefault(n.upper(), (st.file, st.line))
|
|
1447
|
+
return first
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def declared_later(first: dict[str, tuple[str, int]], line: int, file: str) -> set[str]:
|
|
1451
|
+
"""Names whose first declaration comes after `line`: single-pass compilers do not know them
|
|
1452
|
+
yet when a prose line mentions them (oracle, CR300, 2026-10-08)."""
|
|
1453
|
+
return {n for n, (f, l) in first.items() if f == file and l > line}
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _paren_items_words(inner: str, known: set[str]) -> list[str]:
|
|
1457
|
+
"""The undefined names of the text after a call's `(`: with top-level commas, the first three
|
|
1458
|
+
items are walked like a line (words, chain and peek) and the fourth only for its first name,
|
|
1459
|
+
the rest is not read (oracle, CR300, 2026-10-11: `cv(zz, yy, 6, ww) text` names `zz`, `yy`,
|
|
1460
|
+
`ww`; `cv(4, zz yy, 6)` names both). An item starting `&` is a hex literal (`&h0F`) or, in
|
|
1461
|
+
operand position, a name made of the `&` and what follows it up to a blank (`& 6` -> `&`,
|
|
1462
|
+
`&6` -> `&6`); the fourth item's `&h0F` names `h0F`, its other `&` forms name nothing."""
|
|
1463
|
+
items, depth, cur = [], 0, ""
|
|
1464
|
+
for ch in inner:
|
|
1465
|
+
if ch == "(":
|
|
1466
|
+
depth += 1
|
|
1467
|
+
elif ch == ")":
|
|
1468
|
+
if depth == 0:
|
|
1469
|
+
break
|
|
1470
|
+
depth -= 1
|
|
1471
|
+
if ch == "," and depth == 0:
|
|
1472
|
+
items.append(cur)
|
|
1473
|
+
cur = ""
|
|
1474
|
+
else:
|
|
1475
|
+
cur += ch
|
|
1476
|
+
items.append(cur)
|
|
1477
|
+
if len(items) == 1 and "&" not in inner:
|
|
1478
|
+
return _leading_unknown_words(inner, known)
|
|
1479
|
+
words: list[str] = []
|
|
1480
|
+
for idx, item in enumerate(items[:4]):
|
|
1481
|
+
item = item.lstrip(" \t")
|
|
1482
|
+
amp = re.match(r"&([A-Za-z0-9_]*)", item)
|
|
1483
|
+
if amp:
|
|
1484
|
+
hexed = re.match(r"&[hH][0-9A-Fa-f]+", item)
|
|
1485
|
+
if idx == 3:
|
|
1486
|
+
if hexed and amp.group(1) not in known:
|
|
1487
|
+
words.append(amp.group(1))
|
|
1488
|
+
continue
|
|
1489
|
+
if not hexed:
|
|
1490
|
+
words.append(amp.group(0))
|
|
1491
|
+
continue
|
|
1492
|
+
item = "0" + item[hexed.end() :]
|
|
1493
|
+
walked: dict = {}
|
|
1494
|
+
found = _leading_unknown_words(item, known, state=walked)
|
|
1495
|
+
words += found[:1] if idx == 3 else found
|
|
1496
|
+
if walked.get("peeked"):
|
|
1497
|
+
break
|
|
1498
|
+
return words
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _leading_unknown_words(
|
|
1502
|
+
text: str, known: set[str], callables: frozenset = frozenset(), state: dict | None = None
|
|
1503
|
+
) -> list[str]:
|
|
1504
|
+
"""The ordered list of `text`'s own leading bare words this project believes CR300/CR350
|
|
1505
|
+
would report as `%s is not defined.` ahead of the generic fallback for it -- see this
|
|
1506
|
+
module's `check_unknown_line_words` comment above for the oracle evidence and the exact walk
|
|
1507
|
+
this implements. `known` is the same upper-cased name set `expressions._known_names` builds;
|
|
1508
|
+
a word already in it is walked over (it can still be part of the chain) but never returned.
|
|
1509
|
+
"""
|
|
1510
|
+
lead_op = re.match(r"[ \t]*(?:<>|<=|>=|<<|>>|[=<>])[ \t]*(?=[A-Za-z_])", text)
|
|
1511
|
+
if lead_op:
|
|
1512
|
+
# a line that starts with a comparison operator reads the next word as its first operand
|
|
1513
|
+
# and stops there (oracle, CR300/CR350, 2026-10-11: `= Year ` -> `Year`)
|
|
1514
|
+
word = _LINE_WORD_RE.match(text, lead_op.end())
|
|
1515
|
+
assert word is not None # (`lead_op` ends before a letter)
|
|
1516
|
+
return [word.group(0)] if word.group(0).upper() not in known else []
|
|
1517
|
+
# The chain starts at the first word or number (a leading `2 when the` reports `when`).
|
|
1518
|
+
pos = len(text) - len(text.lstrip(" \t"))
|
|
1519
|
+
if re.match(r"-\d", text[pos : pos + 2]):
|
|
1520
|
+
pos += 1 # a signed number starts the chain like a number (oracle, CR300, 2026-10-10: `-1 Then`)
|
|
1521
|
+
elif text[pos : pos + 1] == '"' and text.find('"', pos + 1) > 0:
|
|
1522
|
+
text = text[:pos] + "0" + text[text.find('"', pos + 1) + 1 :] # a string literal likewise
|
|
1523
|
+
if not _LINE_OPERAND_RE.match(text, pos):
|
|
1524
|
+
return []
|
|
1525
|
+
if text[pos : pos + 3].upper() == "IS " or text[pos:].upper() == "IS":
|
|
1526
|
+
return [] # a leading `Is` ends the walk before any word (oracle, CR300, 2026-10-08)
|
|
1527
|
+
words: list[str] = []
|
|
1528
|
+
extending = True # False once we're consuming the single trailing "peek" word.
|
|
1529
|
+
while True:
|
|
1530
|
+
operand = _LINE_OPERAND_RE.match(text, pos) if extending else _LINE_WORD_RE.match(text, pos)
|
|
1531
|
+
if not operand:
|
|
1532
|
+
break
|
|
1533
|
+
pos = operand.end()
|
|
1534
|
+
if operand.group(0)[0].isdigit() or operand.group(0)[0] == '"':
|
|
1535
|
+
pass # a number or a string is part of the chain but never reported
|
|
1536
|
+
elif text.startswith(".", pos):
|
|
1537
|
+
break # `word.` is a table field reference, not a name (oracle, 2026-10-01)
|
|
1538
|
+
else:
|
|
1539
|
+
words.append(operand.group(0))
|
|
1540
|
+
if not extending:
|
|
1541
|
+
break
|
|
1542
|
+
paren = None if operand.group(0)[0].isdigit() or operand.group(0)[0] == '"' else _LINE_PAREN_RE.match(text, pos)
|
|
1543
|
+
if paren and len(words) == 1 and (words[0].upper() not in known or words[0].upper() in callables):
|
|
1544
|
+
# `device (8 channels)`: the first word's parenthesis opens a second walk of the same
|
|
1545
|
+
# shape, which ends at the first `)`, and nothing after it is examined
|
|
1546
|
+
# (oracle, CR300, 2026-10-08).
|
|
1547
|
+
words += _paren_items_words(
|
|
1548
|
+
text[paren.end() :], known
|
|
1549
|
+
) # (a declared variable or function keeps the walk too, oracle 2026-10-10: `X (foo bar)`)
|
|
1550
|
+
break
|
|
1551
|
+
op_match = _LINE_OP_RUN_RE.match(text, pos) or _LINE_OP_WORD_RE.match(text, pos)
|
|
1552
|
+
if op_match:
|
|
1553
|
+
pos = op_match.end()
|
|
1554
|
+
continue
|
|
1555
|
+
ws_match = _LINE_WS_RE.match(text, pos)
|
|
1556
|
+
if ws_match and _LINE_WORD_RE.match(text, ws_match.end()):
|
|
1557
|
+
pos = ws_match.end()
|
|
1558
|
+
extending = False
|
|
1559
|
+
if state is not None:
|
|
1560
|
+
state["peeked"] = True # (a juxtaposed word: the walk is over)
|
|
1561
|
+
continue
|
|
1562
|
+
break
|
|
1563
|
+
return [w for w in words if w.upper() not in known]
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
# `Instruction(a, b c, d)`: the first argument with adjacent operands (oracle, CR300/CR350,
|
|
1567
|
+
# 2026-10-12, a sweep of every instruction and position, `tests/fixtures/r15_cr3xx_call_adjacent_arg.json`):
|
|
1568
|
+
# the arguments before it are read normally; its own first two operands are looked up, then
|
|
1569
|
+
# `Incomplete expression: <arg>.`. A constant-only parameter (codes 1, 5, 20, 38, 39) adds
|
|
1570
|
+
# `Variable illegal in Parameter: <arg>.` when one of those two operands is a variable or an
|
|
1571
|
+
# undefined name; any other parameter is followed by `Too many parameters: <first token of the
|
|
1572
|
+
# next argument>.` when an argument follows. The arguments after it are never read.
|
|
1573
|
+
_CALL_ADJACENT_EXCLUDED = frozenset(
|
|
1574
|
+
{
|
|
1575
|
+
"DATA",
|
|
1576
|
+
"DATALONG",
|
|
1577
|
+
"ALIAS",
|
|
1578
|
+
"UNITS",
|
|
1579
|
+
"SUB",
|
|
1580
|
+
"FUNCTION",
|
|
1581
|
+
"IF",
|
|
1582
|
+
"ELSEIF",
|
|
1583
|
+
"WHILE",
|
|
1584
|
+
"UNTIL",
|
|
1585
|
+
"SELECT",
|
|
1586
|
+
"CASE",
|
|
1587
|
+
"FOR",
|
|
1588
|
+
"DO",
|
|
1589
|
+
"LOOP",
|
|
1590
|
+
}
|
|
1591
|
+
)
|
|
1592
|
+
_ADJACENT_CONST_CODES = frozenset({1, 5, 20, 38, 39})
|
|
1593
|
+
_ADJACENT_EXPR_CODES = frozenset({2, 8, 24, 25, 26, 27, 28, 29, 33, 40, 41, 47, 48})
|
|
1594
|
+
_ADJACENT_RE = re.compile(r"[\w\")]\s+[\w\"]")
|
|
1595
|
+
_ADJACENT_OP_WORD_RE = re.compile(r"(?i)\b(?:and|or|xor|eqv|imp|mod|is|to|not)\b")
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def _split_args_quoted(text: str) -> list[str] | None:
|
|
1599
|
+
"""The top-level comma-separated arguments of a call's text after the `(` (the call's own
|
|
1600
|
+
closing parenthesis stays on the last one); `None` when the parentheses do not balance as one
|
|
1601
|
+
call would."""
|
|
1602
|
+
parts, depth, cur, quoted = [], 0, "", False
|
|
1603
|
+
for ch in text:
|
|
1604
|
+
if ch == '"':
|
|
1605
|
+
quoted = not quoted
|
|
1606
|
+
elif not quoted:
|
|
1607
|
+
if ch == "(":
|
|
1608
|
+
depth += 1
|
|
1609
|
+
elif ch == ")":
|
|
1610
|
+
depth -= 1
|
|
1611
|
+
if depth < -1:
|
|
1612
|
+
return None
|
|
1613
|
+
elif ch == "," and depth == 0:
|
|
1614
|
+
parts.append(cur)
|
|
1615
|
+
cur = ""
|
|
1616
|
+
continue
|
|
1617
|
+
cur += ch
|
|
1618
|
+
parts.append(cur)
|
|
1619
|
+
if quoted:
|
|
1620
|
+
return None
|
|
1621
|
+
return parts
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def _adjacent_operands(arg: str) -> bool:
|
|
1625
|
+
stripped = re.sub(r'"[^"]*"', '"', arg)
|
|
1626
|
+
return bool(_ADJACENT_RE.search(stripped)) and not _ADJACENT_OP_WORD_RE.search(arg)
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
_ADJACENT_TABLE_SOURCES = frozenset({"SAMPLE", "AVERAGE", "MAXIMUM", "MINIMUM", "TOTALIZE", "STDDEV"})
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _call_adjacent_arg_messages(
|
|
1633
|
+
inner: str, record: dict, here: set[str], variables: AbstractSet[str], profile, in_table: bool
|
|
1634
|
+
) -> list[str] | None:
|
|
1635
|
+
params = record["params"]
|
|
1636
|
+
args = _split_args_quoted(inner)
|
|
1637
|
+
if args is None:
|
|
1638
|
+
return None
|
|
1639
|
+
for k, arg in enumerate(args):
|
|
1640
|
+
last = k == len(args) - 1
|
|
1641
|
+
if last and arg.rstrip().endswith(")") and arg.count("(") < arg.count(")"):
|
|
1642
|
+
arg = arg.rstrip()[:-1]
|
|
1643
|
+
if _adjacent_operands(arg):
|
|
1644
|
+
break
|
|
1645
|
+
if k < len(params) and params[k] % 100 in (6, 11):
|
|
1646
|
+
continue # a name or deferred text: not looked at while the statement is read
|
|
1647
|
+
if not arg.strip() or arg.count("(") != arg.count(")") or _leading_unknown_words(arg, here):
|
|
1648
|
+
return None # an earlier argument with problems of its own: not this shape
|
|
1649
|
+
else:
|
|
1650
|
+
return None
|
|
1651
|
+
if k >= len(params):
|
|
1652
|
+
return None
|
|
1653
|
+
code = params[k] % 100
|
|
1654
|
+
if code == 11:
|
|
1655
|
+
# a table source with adjacent operands: its words and the incomplete expression, in a table
|
|
1656
|
+
# (outside one the placement error replaces them); a shortfall hides them
|
|
1657
|
+
if not in_table or record["name"].upper() not in _ADJACENT_TABLE_SOURCES:
|
|
1658
|
+
return None
|
|
1659
|
+
carg = arg.strip() if last else arg.lstrip()
|
|
1660
|
+
short = instr_rules.shortfall_message(record, instr_rules.given_count_of(args))
|
|
1661
|
+
if short:
|
|
1662
|
+
return [short]
|
|
1663
|
+
return [profile.unresolved_symbol_message.format(name=w) for w in _leading_unknown_words(carg, here)] + [
|
|
1664
|
+
f"Incomplete expression: {carg}."
|
|
1665
|
+
]
|
|
1666
|
+
if code not in _ADJACENT_CONST_CODES and code not in _ADJACENT_EXPR_CODES:
|
|
1667
|
+
return None
|
|
1668
|
+
carg = arg.strip() if last else arg.lstrip()
|
|
1669
|
+
out = [profile.unresolved_symbol_message.format(name=w) for w in _leading_unknown_words(carg, here)]
|
|
1670
|
+
out.append(f"Incomplete expression: {carg}.")
|
|
1671
|
+
if code in _ADJACENT_CONST_CODES:
|
|
1672
|
+
chain = _leading_unknown_words(carg, set(), frozenset(profile.functions))
|
|
1673
|
+
if any(w.upper() in variables or w.upper() not in here for w in chain if w.upper() not in profile.functions):
|
|
1674
|
+
out.append(f"Variable illegal in Parameter: {carg}.")
|
|
1675
|
+
elif not last:
|
|
1676
|
+
token = instr_rules._next_simple_token(",".join(args[k + 1 :]))
|
|
1677
|
+
if token:
|
|
1678
|
+
out.append(f"Too many parameters: {token}.")
|
|
1679
|
+
return out
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
# `check_undefined_call_args` (this round): a `Name(args)`/`Name args` call whose own name never
|
|
1683
|
+
# resolves to a `Sub`, a built-in function or an instruction gets, on CR6/CR1000X, only the generic
|
|
1684
|
+
# `Invalid, or out of place expression: %s.` fallback (`structure._check_call_pass2`'s "resolving
|
|
1685
|
+
# to nothing" branch); CR300/CR350 additionally print `{name} is not defined.` for the call's own
|
|
1686
|
+
# name first (`profile.undefined_call_message`, already implemented in `structure.py`) -- but, not
|
|
1687
|
+
# yet modelled anywhere until this round, they *also* print one `%s is not defined.` line per
|
|
1688
|
+
# undefined argument name the call was written with, spliced in between that first line and the
|
|
1689
|
+
# generic fallback -- oracle-confirmed this round (`docs/loggers.md` "CDM_* argument names", both
|
|
1690
|
+
# `cr300comp.exe`/`cr350comp.exe`, identical), corpus: every `CDM_*` instruction this project's own
|
|
1691
|
+
# tables have no record of on CR300/CR350 (`cdmbrfull6w_1.CR6`, `cdmexcitev_1.CR6`, `cdmdelay_1.CR6`,
|
|
1692
|
+
# ... -- CDM support requires hardware these two loggers' own smaller product line never carries),
|
|
1693
|
+
# plus scratch probe `t3.cr6`.
|
|
1694
|
+
#
|
|
1695
|
+
# The rule, walking the call's arguments in source order (oracle probes 2026-10-01):
|
|
1696
|
+
# - Arguments 1-3 are walked fully: bare names, names inside operator expressions (left then
|
|
1697
|
+
# right; unary operands), and names inside index arguments. A declared name is skipped and the
|
|
1698
|
+
# scan continues; a bare DataTable name counts as undefined. Literals and empty slots are skipped.
|
|
1699
|
+
# - An undefined indexed name `B(...)` is reported, then its own arguments are walked, then the
|
|
1700
|
+
# whole scan stops. A defined indexed name `X(...)` has its arguments walked and the scan goes on.
|
|
1701
|
+
# - Argument 4 contributes only its leftmost term; arguments 5+ are never examined.
|
|
1702
|
+
# Examples (undefined names B0.., declared array X):
|
|
1703
|
+
# Foo(B0, B1, B2, B3, B4, B5) -> B0 B1 B2 B3
|
|
1704
|
+
# Foo(1, 2, 3, B3+B4) -> B3
|
|
1705
|
+
# Foo(B0+B1, B2+B3, B4+B5) -> all six
|
|
1706
|
+
# Foo(B0(B9), B1) -> B0 B9
|
|
1707
|
+
# Foo(B0, X(B1), B2) -> B0 B1 B2
|
|
1708
|
+
# Foo(X, 0, Bad0, 2, Bad1) -> Bad0 only (Bad1 is argument 5)
|
|
1709
|
+
# Corpus: `cdmbrhalf3w_1.CR6` (`mv200` is argument 5, never reported).
|
|
1710
|
+
MEMBER_STOP = "\0member" # (oracle, CR300, 2026-10-01: `Foo(Bad,Tb.X)` -> Foo, Bad, nothing else)
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def _call_arg_undefined_names(
|
|
1714
|
+
args: list[S.Expr | None], known: set[str], routines: frozenset[str] = frozenset()
|
|
1715
|
+
) -> list[str]:
|
|
1716
|
+
"""The undefined names among an unresolved call's arguments, in source order -- see
|
|
1717
|
+
`check_undefined_call_args`'s module comment above for the oracle evidence and the rule."""
|
|
1718
|
+
names: list[str] = []
|
|
1719
|
+
tables = getattr(known, "tables", ())
|
|
1720
|
+
|
|
1721
|
+
def undefined(name: str) -> bool:
|
|
1722
|
+
return name.upper() not in known or name.upper() in tables
|
|
1723
|
+
|
|
1724
|
+
def walk(expr: S.Expr | None) -> bool:
|
|
1725
|
+
"""Append `expr`'s undefined names; False once an undefined indexed name or a table
|
|
1726
|
+
member (`Tb.X`, then no fallback line either: `MEMBER_STOP`) ends the scan."""
|
|
1727
|
+
if isinstance(expr, S.Member):
|
|
1728
|
+
names.append(MEMBER_STOP)
|
|
1729
|
+
return False
|
|
1730
|
+
if isinstance(expr, S.Name):
|
|
1731
|
+
if undefined(expr.name):
|
|
1732
|
+
names.append(expr.name)
|
|
1733
|
+
elif isinstance(expr, S.Index):
|
|
1734
|
+
bad = undefined(expr.name)
|
|
1735
|
+
if bad:
|
|
1736
|
+
names.append(expr.name)
|
|
1737
|
+
if not all(walk(a) for a in expr.args) or bad:
|
|
1738
|
+
return False
|
|
1739
|
+
elif isinstance(expr, S.Binary):
|
|
1740
|
+
return walk(expr.left) and walk(expr.right)
|
|
1741
|
+
elif isinstance(expr, S.Unary):
|
|
1742
|
+
return walk(expr.operand)
|
|
1743
|
+
return True
|
|
1744
|
+
|
|
1745
|
+
for arg in args[:3]:
|
|
1746
|
+
if not walk(arg):
|
|
1747
|
+
return names
|
|
1748
|
+
if len(args) > 3 and isinstance(args[3], S.Number) and args[3].text[:2].lower() == "&h":
|
|
1749
|
+
if (
|
|
1750
|
+
undefined(args[3].text[1:]) or args[3].text[1:].upper() in routines
|
|
1751
|
+
): # (a declared variable `h0F` is not named, a Sub's name is: oracle, CR300/CR350, 2026-10-12)
|
|
1752
|
+
names.append(args[3].text[1:]) # (oracle, CR300, 2026-10-11: a fourth `&h0000` names `h0000`)
|
|
1753
|
+
elif len(args) > 3:
|
|
1754
|
+
first = args[3]
|
|
1755
|
+
while isinstance(first, (S.Binary, S.Unary)):
|
|
1756
|
+
first = first.left if isinstance(first, S.Binary) else first.operand
|
|
1757
|
+
if isinstance(first, (S.Name, S.Index)) and undefined(first.name):
|
|
1758
|
+
names.append(first.name)
|
|
1759
|
+
return names
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
def check_undefined_call_args(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1763
|
+
"""CR300/CR350 only; a no-op on every other logger. Called from `checker/__init__.py`, right
|
|
1764
|
+
after `check_unknown_line_words`, in the same tier-B block -- see this module's
|
|
1765
|
+
`check_undefined_call_args` module comment above for the oracle evidence and the exact rule
|
|
1766
|
+
implemented (and its deliberate limits).
|
|
1767
|
+
|
|
1768
|
+
Reacts to `diags.items` already emitted above (the same after-the-fact pattern
|
|
1769
|
+
`check_unknown_line_words` already uses) rather than re-deriving *why* `structure.py` chose to
|
|
1770
|
+
report a given `S.Call` as unresolved: finds each `S.Call` this program's own `program.
|
|
1771
|
+
statements` shows was written without the `Call` keyword and never resolves to a declared
|
|
1772
|
+
`Sub` (`symbols.declared_subs`) or a built-in function (`profile.functions`) -- the same
|
|
1773
|
+
"resolves to nothing" condition `structure._check_call_pass2` itself gates its own
|
|
1774
|
+
`{name} is not defined.` + generic-fallback pair on -- then, only where that exact two-line
|
|
1775
|
+
pair is already sitting in `diags.items` at that statement's own `(line, file)` (confirming
|
|
1776
|
+
`structure.py` actually reported it there, not truncated by an earlier placement cutoff or
|
|
1777
|
+
left unreported for some other reason this module does not re-derive), splices in this
|
|
1778
|
+
statement's own undefined-argument-name lines between them.
|
|
1779
|
+
"""
|
|
1780
|
+
if profile.name not in ("cr300", "cr350"):
|
|
1781
|
+
return
|
|
1782
|
+
if not diags.items:
|
|
1783
|
+
return
|
|
1784
|
+
subs = symbols.declared_subs(program)
|
|
1785
|
+
calls_by_key: dict[tuple[int, str], S.Call] = {}
|
|
1786
|
+
for stmt in program.statements:
|
|
1787
|
+
if not isinstance(stmt, S.Call) or stmt.explicit:
|
|
1788
|
+
continue
|
|
1789
|
+
name = stmt.name.upper()
|
|
1790
|
+
if name in subs or name in profile.functions:
|
|
1791
|
+
continue
|
|
1792
|
+
calls_by_key[(stmt.line, stmt.file)] = stmt
|
|
1793
|
+
if not calls_by_key:
|
|
1794
|
+
return
|
|
1795
|
+
known: set[str] | None = None
|
|
1796
|
+
items = diags.items
|
|
1797
|
+
new_items: list[Diagnostic] = []
|
|
1798
|
+
changed = False
|
|
1799
|
+
i = 0
|
|
1800
|
+
n = len(items)
|
|
1801
|
+
while i < n:
|
|
1802
|
+
d = items[i]
|
|
1803
|
+
stmt = calls_by_key.get((d.line, d.file))
|
|
1804
|
+
nxt = items[i + 1] if i + 1 < n else None
|
|
1805
|
+
if (
|
|
1806
|
+
stmt is not None
|
|
1807
|
+
and d.severity == "error"
|
|
1808
|
+
and d.message == profile.unresolved_symbol_message.format(name=stmt.name)
|
|
1809
|
+
and nxt is not None
|
|
1810
|
+
and nxt.severity == "error"
|
|
1811
|
+
and (nxt.line, nxt.file) == (d.line, d.file)
|
|
1812
|
+
and nxt.message.startswith(_GENERIC_FALLBACK_PREFIX)
|
|
1813
|
+
):
|
|
1814
|
+
if known is None:
|
|
1815
|
+
known = expressions._known_names(program, profile)
|
|
1816
|
+
routines = frozenset(
|
|
1817
|
+
st.name.upper() for st in program.statements if isinstance(st, S.SubDef)
|
|
1818
|
+
) - symbols.declared_variable_names(program)
|
|
1819
|
+
names = _call_arg_undefined_names(stmt.args, known, routines)
|
|
1820
|
+
new_items.append(d)
|
|
1821
|
+
for undefined_name in names:
|
|
1822
|
+
if undefined_name == MEMBER_STOP:
|
|
1823
|
+
i += 1 # a table member ends the call: no fallback line
|
|
1824
|
+
changed = True
|
|
1825
|
+
break
|
|
1826
|
+
new_items.append(
|
|
1827
|
+
Diagnostic(
|
|
1828
|
+
"error", d.line, profile.unresolved_symbol_message.format(name=undefined_name), d.file, True
|
|
1829
|
+
)
|
|
1830
|
+
)
|
|
1831
|
+
changed = True
|
|
1832
|
+
i += 1
|
|
1833
|
+
continue
|
|
1834
|
+
new_items.append(d)
|
|
1835
|
+
i += 1
|
|
1836
|
+
if changed:
|
|
1837
|
+
diags.items[:] = new_items
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def order_email_destination(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1841
|
+
"""CR300/CR350: `A string variable destination is required` (`instr_rules.
|
|
1842
|
+
check_emailrecv_destination`, a pass-1 style check) follows the statement's own `X is not
|
|
1843
|
+
defined.` lines (oracle, 2026-10-08)."""
|
|
1844
|
+
if profile.name not in ("cr300", "cr350"):
|
|
1845
|
+
return
|
|
1846
|
+
items = diags.items
|
|
1847
|
+
for i, d in enumerate(items):
|
|
1848
|
+
if d.message != instr_rules._SERIALIN_DEST_MESSAGE:
|
|
1849
|
+
continue
|
|
1850
|
+
j = i + 1
|
|
1851
|
+
while (
|
|
1852
|
+
j < len(items)
|
|
1853
|
+
and (items[j].line, items[j].file) == (d.line, d.file)
|
|
1854
|
+
and items[j].message.endswith(_UNRESOLVED_SUFFIX)
|
|
1855
|
+
):
|
|
1856
|
+
j += 1
|
|
1857
|
+
if j > i + 1:
|
|
1858
|
+
items[i:j] = items[i + 1 : j] + [d]
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
_SHORTFALL_MESSAGES = ("Invalid or missing parameter.", "Too few parameters in instruction.")
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
def drop_shortfall_with_names(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1865
|
+
"""CR300/CR350: an undefined name hides the too-few-arguments message of its own statement and
|
|
1866
|
+
of every later statement written with arguments, but not of an earlier one (oracle, 2026-10-08/10: `PanelTemp(Nope)`,
|
|
1867
|
+
`Delay(1,Nope)`, `VoltDiff (X,1,mV200,1)` print only the name lines; `PanelTemp(X) : X=Nope`,
|
|
1868
|
+
`PanelTemp(X)` then `PanelTemp(Nope)` keep the first shortfall; `X=Nope : PanelTemp(X)` and
|
|
1869
|
+
`X=Nope` then `PanelTemp(X)` on the next line hide it)."""
|
|
1870
|
+
if profile.name not in ("cr300", "cr350"):
|
|
1871
|
+
return
|
|
1872
|
+
named = {(d.line, d.file) for d in diags.items if d.severity == "error" and d.message.endswith(_UNRESOLVED_SUFFIX)}
|
|
1873
|
+
# `Variable illegal in Parameter` ends the statement like an undefined name does, and hides the
|
|
1874
|
+
# shortfalls of the statement and of the later ones (oracle, CR300, 2026-10-12: `Delay(y)`)
|
|
1875
|
+
illegal = {
|
|
1876
|
+
(d.line, d.file)
|
|
1877
|
+
for d in diags.items
|
|
1878
|
+
if d.severity == "error" and d.message.startswith(_VARIABLE_ILLEGAL_PREFIX)
|
|
1879
|
+
}
|
|
1880
|
+
named |= illegal
|
|
1881
|
+
if not named:
|
|
1882
|
+
return
|
|
1883
|
+
stmts = program.statements
|
|
1884
|
+
known = expressions._known_names(program, profile)
|
|
1885
|
+
|
|
1886
|
+
def has_names(stmt) -> bool:
|
|
1887
|
+
exprs = (
|
|
1888
|
+
stmt.args
|
|
1889
|
+
if isinstance(stmt, S.Instruction)
|
|
1890
|
+
else [stmt.target, stmt.value]
|
|
1891
|
+
if isinstance(stmt, S.Assignment)
|
|
1892
|
+
else []
|
|
1893
|
+
)
|
|
1894
|
+
if isinstance(stmt, S.Instruction) and (stmt.line, stmt.file) in illegal:
|
|
1895
|
+
return True
|
|
1896
|
+
return any(expressions._undefined_names(e, known) for e in exprs)
|
|
1897
|
+
|
|
1898
|
+
def is_short(stmt) -> bool:
|
|
1899
|
+
record = profile.instructions.get(stmt.keyword.upper()) if isinstance(stmt, S.Instruction) else None
|
|
1900
|
+
return record is not None and instr_rules.given_count(stmt, profile) < instr_rules._required_count(
|
|
1901
|
+
instr_rules._real_params(record["params"])
|
|
1902
|
+
)
|
|
1903
|
+
|
|
1904
|
+
first = next((i for i, st in enumerate(stmts) if (st.line, st.file) in named and has_names(st)), None)
|
|
1905
|
+
# A line whose name errors come from no instruction/assignment (prose, `S.Unparsed`) keeps the
|
|
1906
|
+
# per-line rule: it hides only its own line's shortfall.
|
|
1907
|
+
with_names = {(st.line, st.file) for st in stmts if has_names(st)}
|
|
1908
|
+
line_only = named - with_names
|
|
1909
|
+
first_key = None if first is None else (stmts[first].line, stmts[first].file)
|
|
1910
|
+
|
|
1911
|
+
seen: collections.Counter = collections.Counter() # shortfall diagnostics met per line
|
|
1912
|
+
|
|
1913
|
+
def hidden(d: Diagnostic) -> bool:
|
|
1914
|
+
if d.severity != "error" or d.message not in _SHORTFALL_MESSAGES:
|
|
1915
|
+
return False
|
|
1916
|
+
key = (d.line, d.file)
|
|
1917
|
+
idxs = [i for i, st in enumerate(stmts) if (st.line, st.file) == key]
|
|
1918
|
+
if any(isinstance(st := stmts[i], S.Unparsed) and st.reason == structure._FOR_MISSING for i in idxs):
|
|
1919
|
+
return False # a `For` without `=` is never hidden by names (oracle, CR300, 2026-10-10)
|
|
1920
|
+
short = [i for i in idxs if is_short(stmts[i])]
|
|
1921
|
+
nth = seen[key]
|
|
1922
|
+
seen[key] += 1
|
|
1923
|
+
if key in line_only or (key in named and (not idxs or (not short and key == first_key))):
|
|
1924
|
+
return True
|
|
1925
|
+
if first is None:
|
|
1926
|
+
return False
|
|
1927
|
+
cand = short[nth] if nth < len(short) else (short or idxs)[0]
|
|
1928
|
+
# A statement written with no argument at all is never hidden (`X=Nope` then `PanelTemp`).
|
|
1929
|
+
return cand >= first and bool(getattr(stmts[cand], "args", True))
|
|
1930
|
+
|
|
1931
|
+
diags.items[:] = [d for d in diags.items if not hidden(d)]
|
|
1932
|
+
# a `For` without `=` or a short instruction that ends a line whose earlier segment named
|
|
1933
|
+
# undefined words prints after them (oracle, CR300/CR350, 2026-10-10: `X=Nope : PanelTemp`)
|
|
1934
|
+
for i, st in enumerate(stmts):
|
|
1935
|
+
if not ((isinstance(st, S.Unparsed) and st.reason == structure._FOR_MISSING) or is_short(st)):
|
|
1936
|
+
continue
|
|
1937
|
+
key = (st.line, st.file)
|
|
1938
|
+
if i + 1 < len(stmts) and (stmts[i + 1].line, stmts[i + 1].file) == key:
|
|
1939
|
+
continue
|
|
1940
|
+
if not any((o.line, o.file) == key and has_names(o) for o in stmts[:i]):
|
|
1941
|
+
continue
|
|
1942
|
+
if any(
|
|
1943
|
+
(o.line, o.file) == key
|
|
1944
|
+
and o is not st
|
|
1945
|
+
and (is_short(o) or (isinstance(o, S.Unparsed) and o.reason == structure._FOR_MISSING))
|
|
1946
|
+
for o in stmts
|
|
1947
|
+
):
|
|
1948
|
+
continue # (only a line's single shortfall moves)
|
|
1949
|
+
moved = [d for d in diags.items if (d.line, d.file) == key and d.message in _SHORTFALL_MESSAGES]
|
|
1950
|
+
if moved:
|
|
1951
|
+
at = max(j for j, d in enumerate(diags.items) if (d.line, d.file) == key)
|
|
1952
|
+
diags.items[:] = [d for d in diags.items if d is not moved[0]]
|
|
1953
|
+
diags.items.insert(at, moved[0])
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def check_for_header(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1957
|
+
"""CR300/CR350: a `For` header whose `To`/`Step` half has adjacent operands (`structure.
|
|
1958
|
+
_for_header_incomplete`, CR6 pass 1) prints the half's leading undefined words after the
|
|
1959
|
+
`From` expression's, then `Incomplete expression: <half>.` (oracle, CR300/CR350, 2026-10-10:
|
|
1960
|
+
`To 1 Step-1` -> `Step is not defined.`, `Nope To 12 Step-1` -> `Nope`, `Step`, then the line;
|
|
1961
|
+
`Step-Nope` names only `Step`)."""
|
|
1962
|
+
if profile.name not in ("cr300", "cr350"):
|
|
1963
|
+
return
|
|
1964
|
+
known = None
|
|
1965
|
+
for stmt in program.statements:
|
|
1966
|
+
text = getattr(stmt, "text", None) or ""
|
|
1967
|
+
if not re.match(r"\s*For\s", text, re.I):
|
|
1968
|
+
continue
|
|
1969
|
+
found = structure._for_header_incomplete(text, profile)
|
|
1970
|
+
kind = "incomplete"
|
|
1971
|
+
if found is None:
|
|
1972
|
+
parts = structure._for_header_parts(text, profile)
|
|
1973
|
+
if not parts:
|
|
1974
|
+
continue
|
|
1975
|
+
kind = parts[0][0]
|
|
1976
|
+
init = structure._FOR_INIT_RE.match(text)
|
|
1977
|
+
found = (parts[0][1], bool(init) and parts[0][1] == init.group(1))
|
|
1978
|
+
echo, is_init = found
|
|
1979
|
+
key = (stmt.line, stmt.file)
|
|
1980
|
+
own = [d for d in diags.items if (d.line, d.file) == key]
|
|
1981
|
+
if not all(
|
|
1982
|
+
d.message.endswith(_UNRESOLVED_SUFFIX)
|
|
1983
|
+
or d.message.startswith(
|
|
1984
|
+
(_GENERIC_FALLBACK_PREFIX, "Incomplete expression:", "No Matching, or out of place )")
|
|
1985
|
+
)
|
|
1986
|
+
for d in own
|
|
1987
|
+
):
|
|
1988
|
+
continue
|
|
1989
|
+
if known is None:
|
|
1990
|
+
known = prose_known(program, profile)
|
|
1991
|
+
here = known - declared_later(first_declarations(program), stmt.line, stmt.file)
|
|
1992
|
+
header = structure._FOR_TO_RE.match(text)
|
|
1993
|
+
assert header is not None # (`_for_header_incomplete` matched it)
|
|
1994
|
+
start = header.start(1)
|
|
1995
|
+
eq = re.search(r"=(.*?)\sTo\s", text[:start], re.I | re.S)
|
|
1996
|
+
words = ([] if is_init or not eq else _leading_unknown_words(eq.group(1), here)) + _leading_unknown_words(
|
|
1997
|
+
echo, here
|
|
1998
|
+
)
|
|
1999
|
+
at = next((i for i, d in enumerate(diags.items) if (d.line, d.file) == key), len(diags.items))
|
|
2000
|
+
diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
|
|
2001
|
+
diags.items[at:at] = [
|
|
2002
|
+
Diagnostic("error", stmt.line, profile.unresolved_symbol_message.format(name=w), stmt.file, True)
|
|
2003
|
+
for w in words
|
|
2004
|
+
] + [Diagnostic("error", stmt.line, structure._for_header_message(kind, echo), stmt.file)]
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
def _echo_key(text: str) -> str:
|
|
2008
|
+
"""An echoed statement compared with its `S.Unparsed` text (the echo drops outer parentheses)."""
|
|
2009
|
+
return text.replace("(", "").replace(")", "").strip()
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
def check_unknown_line_words(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2013
|
+
"""CR300/CR350 only; a no-op on every other logger, and on a file with no `S.Unparsed`
|
|
2014
|
+
statement at all. Called from `checker/__init__.py`, right after `check_expr_cascade`, once
|
|
2015
|
+
`structure.check_pass2` (and `expressions.check`) have had a chance to emit the generic
|
|
2016
|
+
`Invalid, or out of place expression: %s.` fallback for an `S.Unparsed` statement -- see
|
|
2017
|
+
`checker/structure.py`'s own `S.Unparsed`-`else` branch, whose before-`BeginProg` follow-up/
|
|
2018
|
+
stop is separately carved out for CR300/CR350 there (`profile.undefined_call_message`, mirroring
|
|
2019
|
+
`_check_call_pass2`'s identical carve-out; `docs/loggers.md` "Round 8" Finding 2), so a file with
|
|
2020
|
+
several such statements gets every one of them reacted to here, not just the first.
|
|
2021
|
+
|
|
2022
|
+
Deliberately reacts only to diagnostics already sitting in `diags.items` (the same
|
|
2023
|
+
after-the-fact pattern `check_expr_cascade` above already uses) rather than re-deriving *why*
|
|
2024
|
+
`structure.py` chose to emit the fallback for a given statement, so it stays correct however
|
|
2025
|
+
that gating logic is refactored, and is restricted to lines that `program.statements` itself
|
|
2026
|
+
marks `S.Unparsed` -- deliberately **not** every statement shape that can reach the same
|
|
2027
|
+
generic message (`checker/structure.py`'s own docstrings: a bare unresolved call already gets
|
|
2028
|
+
its own, already-implemented, single-name CR300/CR350 line via `profile.
|
|
2029
|
+
undefined_call_message` above, in `_check_call_pass2`; an assignment with an unresolved target
|
|
2030
|
+
was not oracle-tested this round for this same leading-word behaviour, so is left alone here
|
|
2031
|
+
rather than guessed at).
|
|
2032
|
+
"""
|
|
2033
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2034
|
+
return
|
|
2035
|
+
if not diags.items:
|
|
2036
|
+
return
|
|
2037
|
+
unparsed_lines = {(s.line, s.file) for s in program.statements if isinstance(s, S.Unparsed)}
|
|
2038
|
+
# (a line with several `:`-separated statements echoes each one; only the Unparsed one counts)
|
|
2039
|
+
unparsed_text = {(s.line, s.file, _echo_key(s.text)): s for s in program.statements if isinstance(s, S.Unparsed)}
|
|
2040
|
+
if not unparsed_lines:
|
|
2041
|
+
return
|
|
2042
|
+
known: set[str] | None = None
|
|
2043
|
+
first: dict[str, tuple[str, int]] | None = None
|
|
2044
|
+
new_items: list[Diagnostic] = []
|
|
2045
|
+
changed = False
|
|
2046
|
+
for d in diags.items:
|
|
2047
|
+
if (
|
|
2048
|
+
d.severity == "error"
|
|
2049
|
+
and d.message.endswith(".")
|
|
2050
|
+
and d.message.startswith(_GENERIC_FALLBACK_PREFIX)
|
|
2051
|
+
and (d.line, d.file) in unparsed_lines
|
|
2052
|
+
and (d.line, d.file, _echo_key(d.message[len(_GENERIC_FALLBACK_PREFIX) : -1])) in unparsed_text
|
|
2053
|
+
):
|
|
2054
|
+
if known is None:
|
|
2055
|
+
known = prose_known(program, profile)
|
|
2056
|
+
if first is None:
|
|
2057
|
+
first = first_declarations(program)
|
|
2058
|
+
here = known - declared_later(first, d.line, d.file)
|
|
2059
|
+
text = d.message[len(_GENERIC_FALLBACK_PREFIX) : -1]
|
|
2060
|
+
if re.match(r"(?i)\s*Alias\b", text) and any(
|
|
2061
|
+
(e.line, e.file) == (d.line, d.file) and e.message.startswith("Need '=' in alias definition")
|
|
2062
|
+
for e in diags.items
|
|
2063
|
+
):
|
|
2064
|
+
# an `Alias` line without its `=` has been answered by the alias message alone
|
|
2065
|
+
# (oracle, CR300/CR350, 2026-10-11: `Alias rTime(1)` / `= Year` on the next line)
|
|
2066
|
+
changed = True
|
|
2067
|
+
continue
|
|
2068
|
+
table = _TABLE_LINE_RE.match(text)
|
|
2069
|
+
signed = None if table else _SIGNED_TABLE_RE.match(text)
|
|
2070
|
+
if signed and (
|
|
2071
|
+
signed.group(1).upper() in symbols._ALWAYS_KNOWN_SYSTEM_TABLES
|
|
2072
|
+
or signed.group(1).upper() in prose_known_tables(program, profile)
|
|
2073
|
+
):
|
|
2074
|
+
signed = None
|
|
2075
|
+
table = table or signed
|
|
2076
|
+
if table:
|
|
2077
|
+
new_items.append(
|
|
2078
|
+
Diagnostic("error", d.line, f"Could not find DataTable {table.group(1)}.", d.file, cascade=False)
|
|
2079
|
+
)
|
|
2080
|
+
d = dataclasses.replace(d, cascade=False) # (no cascade: oracle, `r1` probe)
|
|
2081
|
+
changed = True
|
|
2082
|
+
if _loose_before_beginprog(program, d.line, d.file):
|
|
2083
|
+
# (oracle, CR300, 2026-10-11: `foo.` before `BeginProg` also gets the placement line)
|
|
2084
|
+
new_items.append(d)
|
|
2085
|
+
new_items.append(
|
|
2086
|
+
Diagnostic("error", d.line, "Expr illegally occurs before BeginProg.", d.file, cascade=False)
|
|
2087
|
+
)
|
|
2088
|
+
continue
|
|
2089
|
+
bare = text.rstrip("\n")
|
|
2090
|
+
cp = structure.condition_part(bare)
|
|
2091
|
+
if (
|
|
2092
|
+
cp is not None
|
|
2093
|
+
and not cp.strip()
|
|
2094
|
+
and not (
|
|
2095
|
+
re.match(r"(?i)\s*(?:DO|LOOP)\b", bare)
|
|
2096
|
+
and any(e.severity == "error" and e.line and e.line < d.line for e in diags.items)
|
|
2097
|
+
)
|
|
2098
|
+
and (empty := structure._pass1_scan(bare, structure._Everything(), profile)) is not None
|
|
2099
|
+
and empty in (("pass1", "missing_expr"), ("pass1", "fallback"))
|
|
2100
|
+
):
|
|
2101
|
+
# a block keyword with no condition at all: the same pass-1 verdicts as CR6/CR1000X
|
|
2102
|
+
# (oracle, CR300/CR350, 2026-10-11: bare `If`/`ElseIf`/`Case`/`Loop While` are
|
|
2103
|
+
# `Invalid or missing expression.`, `If ` and `Select Case` an empty echo)
|
|
2104
|
+
new_items.append(
|
|
2105
|
+
dataclasses.replace(
|
|
2106
|
+
d,
|
|
2107
|
+
message=(
|
|
2108
|
+
"Invalid or missing expression."
|
|
2109
|
+
if empty[1] == "missing_expr"
|
|
2110
|
+
else f"{_GENERIC_FALLBACK_PREFIX}."
|
|
2111
|
+
),
|
|
2112
|
+
)
|
|
2113
|
+
)
|
|
2114
|
+
changed = True
|
|
2115
|
+
continue
|
|
2116
|
+
zero = _ZERO_TAIL_RE.match(bare)
|
|
2117
|
+
if (
|
|
2118
|
+
zero
|
|
2119
|
+
and zero.group(1).upper() in _TAIL_REJECTING
|
|
2120
|
+
and (tok := instr_rules._next_simple_token(zero.group(2)))
|
|
2121
|
+
):
|
|
2122
|
+
# an instruction with no parameters followed by text: the first token is the
|
|
2123
|
+
# surplus (oracle, CR300/CR350, 2026-10-11: `BeginProg, EndProg (Begin Program,
|
|
2124
|
+
# End Program)` -> `Too many parameters: EndProg.`)
|
|
2125
|
+
new_items.append(Diagnostic("error", d.line, f"Too many parameters: {tok}.", d.file))
|
|
2126
|
+
changed = True
|
|
2127
|
+
continue
|
|
2128
|
+
call = re.match(r"\s*([A-Za-z_]\w*)\s*\((.*)$", bare, re.S)
|
|
2129
|
+
cinfo = profile.instructions.get(call.group(1).upper()) if call else None
|
|
2130
|
+
if (
|
|
2131
|
+
call
|
|
2132
|
+
and cinfo
|
|
2133
|
+
and cinfo["params"]
|
|
2134
|
+
and call.group(1).upper() not in _CALL_ADJACENT_EXCLUDED
|
|
2135
|
+
and (
|
|
2136
|
+
found := _call_adjacent_arg_messages(
|
|
2137
|
+
call.group(2),
|
|
2138
|
+
cinfo,
|
|
2139
|
+
here,
|
|
2140
|
+
symbols.declared_variable_names(program),
|
|
2141
|
+
profile,
|
|
2142
|
+
_inside_table(program, d.line, d.file),
|
|
2143
|
+
)
|
|
2144
|
+
)
|
|
2145
|
+
is not None
|
|
2146
|
+
):
|
|
2147
|
+
for message in found:
|
|
2148
|
+
new_items.append(Diagnostic("error", d.line, message, d.file))
|
|
2149
|
+
changed = True
|
|
2150
|
+
continue
|
|
2151
|
+
if (
|
|
2152
|
+
call
|
|
2153
|
+
and cinfo
|
|
2154
|
+
and cinfo["params"]
|
|
2155
|
+
and cinfo["params"][0] % 100 not in (6, 11)
|
|
2156
|
+
and call.group(1).upper()
|
|
2157
|
+
not in (
|
|
2158
|
+
"DATA",
|
|
2159
|
+
"DATALONG",
|
|
2160
|
+
"ALIAS",
|
|
2161
|
+
"UNITS",
|
|
2162
|
+
"SUB",
|
|
2163
|
+
"FUNCTION",
|
|
2164
|
+
"IF",
|
|
2165
|
+
"ELSEIF",
|
|
2166
|
+
"WHILE",
|
|
2167
|
+
"UNTIL",
|
|
2168
|
+
"SELECT",
|
|
2169
|
+
"CASE",
|
|
2170
|
+
"FOR",
|
|
2171
|
+
"DO",
|
|
2172
|
+
"LOOP",
|
|
2173
|
+
)
|
|
2174
|
+
and (carg := _split_top_commas(call.group(2))[0].rstrip())
|
|
2175
|
+
and re.search(r"[\w\"]\s+[\w\"]", re.sub(r'"[^"]*"', '"', carg))
|
|
2176
|
+
and not re.search(r"(?i)\b(?:and|or|xor|eqv|imp|mod|is|to|not)\b", carg)
|
|
2177
|
+
):
|
|
2178
|
+
# `Instruction(a b, ...)`: the first argument is read as an expression with
|
|
2179
|
+
# adjacent operands (oracle, CR300/CR350, 2026-10-11: `MQTTPublishTable(MQTT QoS,
|
|
2180
|
+
# ...)` -> both words, `Incomplete expression: MQTT QoS.`, and for a constant-only
|
|
2181
|
+
# parameter `Variable illegal in Parameter: MQTT QoS.`); the rest is never read
|
|
2182
|
+
if carg.endswith(")") and carg.count("(") < carg.count(")"):
|
|
2183
|
+
carg = carg[:-1].rstrip() # (the call's own closing parenthesis)
|
|
2184
|
+
for word in _leading_unknown_words(carg, here):
|
|
2185
|
+
new_items.append(
|
|
2186
|
+
Diagnostic("error", d.line, profile.unresolved_symbol_message.format(name=word), d.file)
|
|
2187
|
+
)
|
|
2188
|
+
new_items.append(Diagnostic("error", d.line, f"Incomplete expression: {carg}.", d.file))
|
|
2189
|
+
if cinfo["params"][0] in _CONST_FIRST_PARAM:
|
|
2190
|
+
new_items.append(Diagnostic("error", d.line, f"Variable illegal in Parameter: {carg}.", d.file))
|
|
2191
|
+
changed = True
|
|
2192
|
+
continue
|
|
2193
|
+
lead = _INSTRUCTION_PROSE_RE.match(text)
|
|
2194
|
+
info = profile.instructions.get(lead.group(1).upper()) if lead else None
|
|
2195
|
+
if (
|
|
2196
|
+
lead
|
|
2197
|
+
and info
|
|
2198
|
+
and lead.group(1).upper() not in ("DATA", "DATALONG")
|
|
2199
|
+
and info["params"]
|
|
2200
|
+
and not (structure.condition_part(text) or "").strip()
|
|
2201
|
+
and not structure.inline_if_condition(text)
|
|
2202
|
+
):
|
|
2203
|
+
# `Battery sensor using the`: the instruction reads the prose as its argument
|
|
2204
|
+
# (oracle, CR300, 2026-10-01): its words, `Incomplete expression: <prose>.`, and
|
|
2205
|
+
# for a constant-only first parameter `Variable illegal in Parameter: <prose>.`
|
|
2206
|
+
rest = lead.group(2).rstrip("\n")
|
|
2207
|
+
codes = list(info["params"])
|
|
2208
|
+
if codes[0] % 100 == 11 and "," not in rest and len(codes) > 1 and codes[1] < 100:
|
|
2209
|
+
# a deferred-text first parameter (`Histogram`, `FFT`, ...) takes the whole text
|
|
2210
|
+
# unread; the next required parameter is then missing (oracle, CR300/CR350,
|
|
2211
|
+
# 2026-10-11)
|
|
2212
|
+
new_items.append(Diagnostic("error", d.line, "Invalid or missing parameter.", d.file))
|
|
2213
|
+
changed = True
|
|
2214
|
+
continue
|
|
2215
|
+
if codes[0] % 100 == 6:
|
|
2216
|
+
# a name token (`CallTable`, `DataTable`, `FileMark`): the first word is taken
|
|
2217
|
+
# unchecked, the rest is the next parameter, or the surplus when there is none
|
|
2218
|
+
name_tok = instr_rules._next_simple_token(rest)
|
|
2219
|
+
rest = rest[rest.index(name_tok) + len(name_tok) :] if name_tok else rest
|
|
2220
|
+
codes = codes[1:]
|
|
2221
|
+
if not codes:
|
|
2222
|
+
if tok := instr_rules._next_simple_token(rest):
|
|
2223
|
+
new_items.append(Diagnostic("error", d.line, f"Too many parameters: {tok}.", d.file))
|
|
2224
|
+
changed = True
|
|
2225
|
+
continue
|
|
2226
|
+
rest = rest.lstrip(" \t")
|
|
2227
|
+
if codes and rest:
|
|
2228
|
+
op = _LEAD_OP_WORD_RE.match(rest)
|
|
2229
|
+
words = [] if op else _leading_unknown_words(rest, here)
|
|
2230
|
+
after = ""
|
|
2231
|
+
if op and op.group(1).upper() not in _LEAD_OP_NO_OPERAND:
|
|
2232
|
+
# a leading binary operator word: the next operand is read, then the
|
|
2233
|
+
# expression is out of place (oracle, CR300/CR350, 2026-10-11)
|
|
2234
|
+
after = _LEAD_OP_SKIP_RE.sub("", rest[op.end() :], count=1)
|
|
2235
|
+
words = _leading_unknown_words(after, here)[:1] if _LINE_WORD_RE.match(after) else []
|
|
2236
|
+
for word in words:
|
|
2237
|
+
new_items.append(
|
|
2238
|
+
Diagnostic("error", d.line, profile.unresolved_symbol_message.format(name=word), d.file)
|
|
2239
|
+
)
|
|
2240
|
+
new_items.append(
|
|
2241
|
+
Diagnostic(
|
|
2242
|
+
"error",
|
|
2243
|
+
d.line,
|
|
2244
|
+
(f"{_GENERIC_FALLBACK_PREFIX}{rest}." if op else f"Incomplete expression: {rest}."),
|
|
2245
|
+
d.file,
|
|
2246
|
+
)
|
|
2247
|
+
)
|
|
2248
|
+
if codes[0] in _CONST_FIRST_PARAM and not (op and not _LINE_WORD_RE.match(after)):
|
|
2249
|
+
new_items.append(Diagnostic("error", d.line, f"Variable illegal in Parameter: {rest}.", d.file))
|
|
2250
|
+
changed = True
|
|
2251
|
+
continue
|
|
2252
|
+
callables = frozenset(symbols.declared_variable_names(program) | set(profile.functions))
|
|
2253
|
+
fw = _LINE_WORD_RE.match(text, len(text) - len(text.lstrip(" \t")))
|
|
2254
|
+
needs_paren = bool(
|
|
2255
|
+
fw
|
|
2256
|
+
and fw.group(0).upper() not in profile.instructions
|
|
2257
|
+
and not text[fw.end() :].lstrip(" \t").startswith(("(", "."))
|
|
2258
|
+
and fw.group(0).upper() not in symbols.declared_variable_names(program)
|
|
2259
|
+
and structure._bare_function_needs_paren(fw.group(0).upper(), profile.functions)
|
|
2260
|
+
)
|
|
2261
|
+
cond_text = structure.condition_part(text)
|
|
2262
|
+
cond_text = cond_text if cond_text and cond_text.strip() else None
|
|
2263
|
+
if cond_text is None and re.match(r"(?i)\s*if\b", text):
|
|
2264
|
+
cond_text = structure.inline_if_condition(text) or None # (a one-line `If`'s condition)
|
|
2265
|
+
words = _leading_unknown_words(text if cond_text is None else cond_text, here, callables)
|
|
2266
|
+
if needs_paren and fw:
|
|
2267
|
+
# the operands after a parenthesis-less function are named (oracle, CR300,
|
|
2268
|
+
# 2026-10-10: `Timer is bar baz` -> `bar`, `baz`)
|
|
2269
|
+
rest = text[fw.end() :] # (a leading `is` counts as an operand, never named)
|
|
2270
|
+
if re.fullmatch(r"[\w\s]*", rest):
|
|
2271
|
+
# as many operands as the function has parameters are read (oracle, CR300,
|
|
2272
|
+
# 2026-10-10: `Timer foo bar baz` names three, `COS foo bar` one)
|
|
2273
|
+
count = profile.functions[fw.group(0).upper()].get("nargs") or 1
|
|
2274
|
+
words = [
|
|
2275
|
+
w
|
|
2276
|
+
for w in re.findall(r"[A-Za-z_]\w*|\d+", rest)[:count]
|
|
2277
|
+
if not w[0].isdigit() and w.upper() not in here and w.upper() != "IS"
|
|
2278
|
+
]
|
|
2279
|
+
else:
|
|
2280
|
+
words = _leading_unknown_words(rest, here)
|
|
2281
|
+
elif re.match(r"(?i)\s*NOT\s", text):
|
|
2282
|
+
words = _leading_unknown_words(re.sub(r"(?i)^\s*NOT\b", "", text), here)
|
|
2283
|
+
for word in words:
|
|
2284
|
+
new_items.append(
|
|
2285
|
+
Diagnostic("error", d.line, profile.unresolved_symbol_message.format(name=word), d.file, True)
|
|
2286
|
+
)
|
|
2287
|
+
changed = True
|
|
2288
|
+
if needs_paren:
|
|
2289
|
+
# a built-in function used as the first word without its parenthesis (oracle,
|
|
2290
|
+
# CR300/CR350, 2026-10-08: `Sin foo`, `Timer 1`, `COS of angle`)
|
|
2291
|
+
d = dataclasses.replace(d, message=f"Expression expects ( : {text}.")
|
|
2292
|
+
changed = True
|
|
2293
|
+
if d.message.startswith(_GENERIC_FALLBACK_PREFIX) and (
|
|
2294
|
+
stmt := unparsed_text.get((d.line, d.file, _echo_key(text)))
|
|
2295
|
+
):
|
|
2296
|
+
# an assignment's pass-1 syntax verdicts are the same words on CR300/CR350
|
|
2297
|
+
# (oracle, 2026-10-10: `X = 1 2` -> `Incomplete expression`, `X = (1 2)` -> `No Matching,
|
|
2298
|
+
# or out of place )`), whatever names precede them
|
|
2299
|
+
cond = structure.condition_part(stmt.text)
|
|
2300
|
+
cond = cond if cond and cond.strip() else None
|
|
2301
|
+
scan_text = stmt.text
|
|
2302
|
+
inline = structure.inline_if_condition(stmt.text) if cond is None else None
|
|
2303
|
+
if inline and ";" in inline:
|
|
2304
|
+
cond, inline = inline, None # `If X ; = 1 Then Y = 2`: the condition alone
|
|
2305
|
+
if inline:
|
|
2306
|
+
# `If X Y Then Y = 1`: an unreadable condition is reported alone, as for a block
|
|
2307
|
+
# `If` (oracle, CR300/CR350 and CR6, 2026-10-10)
|
|
2308
|
+
scan_text = "If " + inline + "Then"
|
|
2309
|
+
inline_scan = structure._pass1_scan(scan_text, structure._Everything(), profile)
|
|
2310
|
+
if (
|
|
2311
|
+
inline_scan
|
|
2312
|
+
and inline_scan[0] == "pass1"
|
|
2313
|
+
and inline_scan[1] in ("incomplete", "no_matching", "fallback")
|
|
2314
|
+
):
|
|
2315
|
+
cond = inline
|
|
2316
|
+
verdict = (
|
|
2317
|
+
structure._pass1_scan(scan_text, structure._Everything(), profile)
|
|
2318
|
+
if cond is not None
|
|
2319
|
+
or re.match(r"\s*(?:[A-Za-z_]\w*\s*=|NOT\b)", stmt.text, re.I)
|
|
2320
|
+
or re.match(r"\s*(?:\d[\w.]*|-\s*\d[\w.]*|\"[^\"]*\")\s*=", stmt.text)
|
|
2321
|
+
or re.match(r"\s*[A-Za-z_]\w*\s+(?:AND|OR|XOR|IMP|EQV)\b[^=]*[\w)]\s*=", stmt.text, re.I)
|
|
2322
|
+
or re.search(r"\b(?:AND|OR|XOR|IMP|EQV)\b[^=]*=", stmt.text, re.I)
|
|
2323
|
+
else None
|
|
2324
|
+
) # (prose stays generic)
|
|
2325
|
+
head = (
|
|
2326
|
+
{
|
|
2327
|
+
"invalid_assignment": "Invalid assignment in expression: ",
|
|
2328
|
+
"incomplete": "Incomplete expression: ",
|
|
2329
|
+
"no_matching": "No Matching, or out of place ) in expression: ",
|
|
2330
|
+
"expects_comma": "Expression expects , : ",
|
|
2331
|
+
}.get(verdict[1])
|
|
2332
|
+
if verdict and verdict[0] == "pass1"
|
|
2333
|
+
else None
|
|
2334
|
+
)
|
|
2335
|
+
call = re.match(r"\s*([A-Za-z_]\w*)\s*\(([^()]*)", stmt.text)
|
|
2336
|
+
juxt = (
|
|
2337
|
+
call
|
|
2338
|
+
and stmt.reason != "lex error: unterminated string"
|
|
2339
|
+
and re.search(r"[\w\"]\s+(?!(?:AND|OR|XOR|MOD|IMP|EQV)\b)[A-Za-z_\d\"]", call.group(2), re.I)
|
|
2340
|
+
)
|
|
2341
|
+
if (
|
|
2342
|
+
head is None
|
|
2343
|
+
and call
|
|
2344
|
+
and call.group(1).upper() in profile.functions
|
|
2345
|
+
and call.group(1).upper() not in symbols.declared_variable_names(program)
|
|
2346
|
+
and juxt
|
|
2347
|
+
):
|
|
2348
|
+
# a built-in call whose argument list holds juxtaposed operands (oracle, CR300,
|
|
2349
|
+
# 2026-10-10: `Sin (foo bar)`, `ArrayIndex (Array Index)`): `Expression expects ,`
|
|
2350
|
+
# when the argument is not the function's last, else `No Matching` (oracle,
|
|
2351
|
+
# CR300, 2026-10-11: `Round (A B, 1)`, `UDPDataGram("a",UDP Port, ...) - tail`
|
|
2352
|
+
# expect a comma, `Sin (A B)`, `Round (A, 1 B)` do not); text after the `)` is not read
|
|
2353
|
+
k = call.group(2)[: juxt.start() + 1].count(",") + 1
|
|
2354
|
+
nargs = profile.functions[call.group(1).upper()].get("nargs") or 0
|
|
2355
|
+
head = "Expression expects , : " if k < nargs else "No Matching, or out of place ) in expression: "
|
|
2356
|
+
if call.group(1).upper() in structure._SELF_CLOSING_FUNCTIONS:
|
|
2357
|
+
head = "Incomplete expression: " # (`Abs(x y)`, oracle, CR300, 2026-10-12)
|
|
2358
|
+
rhs_call = re.match(r"\s*[A-Za-z_]\w*\s*=\s*([A-Za-z_]\w*)\s*\((.*)$", stmt.text, re.S)
|
|
2359
|
+
if (
|
|
2360
|
+
verdict
|
|
2361
|
+
and verdict[0] == "pass1"
|
|
2362
|
+
and rhs_call
|
|
2363
|
+
and rhs_call.group(1).upper() in profile.functions
|
|
2364
|
+
and not words
|
|
2365
|
+
and (
|
|
2366
|
+
verdict[1] in ("no_matching", "expects_comma")
|
|
2367
|
+
or (
|
|
2368
|
+
verdict[1] == "incomplete"
|
|
2369
|
+
and rhs_call.group(1).upper() in structure._SELF_CLOSING_FUNCTIONS
|
|
2370
|
+
)
|
|
2371
|
+
)
|
|
2372
|
+
):
|
|
2373
|
+
# the bad argument's own undefined words come first (oracle, CR300, 2026-10-12:
|
|
2374
|
+
# `x = Sin(Zork Y)` -> `Zork is not defined.` then the message)
|
|
2375
|
+
parts = _split_args_quoted(rhs_call.group(2)) or []
|
|
2376
|
+
bad = next((a for a in parts if _adjacent_operands(a)), None)
|
|
2377
|
+
for word in _leading_unknown_words(bad.strip(), here) if bad else []:
|
|
2378
|
+
new_items.append(
|
|
2379
|
+
Diagnostic(
|
|
2380
|
+
"error", d.line, profile.unresolved_symbol_message.format(name=word), d.file, True
|
|
2381
|
+
)
|
|
2382
|
+
)
|
|
2383
|
+
if (
|
|
2384
|
+
head == "No Matching, or out of place ) in expression: "
|
|
2385
|
+
and cond is None
|
|
2386
|
+
and stmt.reason != "lex error: unterminated string"
|
|
2387
|
+
and (
|
|
2388
|
+
refined := structure._unclosed_call_message(
|
|
2389
|
+
profile, stmt.text, stmt.at_eol, symbols.declared_variable_names(program)
|
|
2390
|
+
)
|
|
2391
|
+
)
|
|
2392
|
+
):
|
|
2393
|
+
# a call left open at the end of the line: its argument count decides the message
|
|
2394
|
+
# as on CR6/CR1000X (oracle, CR300/CR350, 2026-10-11: `x = Round(x` -> `Expression
|
|
2395
|
+
# expects ,`, `x = FileOpen (s, "r",` -> the generic fallback)
|
|
2396
|
+
d = dataclasses.replace(d, message=refined)
|
|
2397
|
+
changed = True
|
|
2398
|
+
elif cond is not None:
|
|
2399
|
+
if (
|
|
2400
|
+
head == "No Matching, or out of place ) in expression: "
|
|
2401
|
+
and (
|
|
2402
|
+
refined := structure._unclosed_call_message(
|
|
2403
|
+
profile, cond, False, symbols.declared_variable_names(program)
|
|
2404
|
+
)
|
|
2405
|
+
)
|
|
2406
|
+
and refined.startswith("Expression expects ,")
|
|
2407
|
+
):
|
|
2408
|
+
# `If Round(X Then`: a call left open in a condition, same count rule as a
|
|
2409
|
+
# statement (oracle, CR300/CR350, 2026-10-12)
|
|
2410
|
+
head = "Expression expects , : "
|
|
2411
|
+
# a `While`/`Do While|Until`/`Loop While|Until` condition echoes alone, no
|
|
2412
|
+
# line-end newline (oracle, CR300/CR350, 2026-10-10)
|
|
2413
|
+
if head is None and ";" in cond: # (oracle, 2026-10-12: `Do While X ;`, `Case 1 ;`)
|
|
2414
|
+
head = "Invalid symbol in expression: "
|
|
2415
|
+
d = dataclasses.replace(d, message=(head or _GENERIC_FALLBACK_PREFIX) + cond + ".")
|
|
2416
|
+
changed = True
|
|
2417
|
+
elif head:
|
|
2418
|
+
d = dataclasses.replace(d, message=head + d.message[len(_GENERIC_FALLBACK_PREFIX) :])
|
|
2419
|
+
changed = True
|
|
2420
|
+
new_items.append(d)
|
|
2421
|
+
if changed:
|
|
2422
|
+
diags.items[:] = new_items
|
|
2423
|
+
return
|
|
2424
|
+
|
|
2425
|
+
|
|
2426
|
+
_ARITY_PREFIXES = {
|
|
2427
|
+
"many": "No Matching, or out of place ) in expression: ",
|
|
2428
|
+
"few": "Expression expects , : ",
|
|
2429
|
+
"empty": "Invalid, or out of place expression: ",
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
|
|
2433
|
+
class _ArityState(TypedDict, total=False):
|
|
2434
|
+
names: list[str]
|
|
2435
|
+
kind: str
|
|
2436
|
+
bad: bool
|
|
2437
|
+
stop: bool
|
|
2438
|
+
|
|
2439
|
+
|
|
2440
|
+
def _arity_walk(expr: S.Expr | None, known: set[str], profile, state: _ArityState) -> None:
|
|
2441
|
+
"""Left-to-right parse-order walk collecting `state["names"]` (undefined names met) until a
|
|
2442
|
+
built-in call with too many arguments stops it at its first surplus argument
|
|
2443
|
+
(`state["kind"] == "many"`); too few arguments only mark `state["kind"] == "few"`."""
|
|
2444
|
+
if expr is None or state.get("stop"):
|
|
2445
|
+
return
|
|
2446
|
+
if isinstance(expr, S.Name):
|
|
2447
|
+
if expr.name.upper() not in known or expr.name.upper() in getattr(known, "tables", ()):
|
|
2448
|
+
state["names"].append(expr.name)
|
|
2449
|
+
return
|
|
2450
|
+
if isinstance(expr, S.Index):
|
|
2451
|
+
if expr.name.upper() not in known:
|
|
2452
|
+
state["names"].append(expr.name)
|
|
2453
|
+
problem = expressions._call_arity_problem(expr.name, expr.args, profile)
|
|
2454
|
+
args, stop = expr.args, False
|
|
2455
|
+
if problem == "many":
|
|
2456
|
+
at = next(
|
|
2457
|
+
k
|
|
2458
|
+
for k in range(len(args))
|
|
2459
|
+
if expressions._call_arity_problem(expr.name, args[: k + 1], profile) == "many"
|
|
2460
|
+
)
|
|
2461
|
+
args, stop = args[:at], True
|
|
2462
|
+
state.setdefault("kind", "many")
|
|
2463
|
+
elif problem in ("few", "empty"):
|
|
2464
|
+
state.setdefault("kind", problem)
|
|
2465
|
+
elif problem:
|
|
2466
|
+
state["bad"] = True
|
|
2467
|
+
for a in args:
|
|
2468
|
+
_arity_walk(a, known, profile, state)
|
|
2469
|
+
state["stop"] = state.get("stop") or stop
|
|
2470
|
+
return
|
|
2471
|
+
if isinstance(expr, S.Unary):
|
|
2472
|
+
_arity_walk(expr.operand, known, profile, state)
|
|
2473
|
+
elif isinstance(expr, S.Binary):
|
|
2474
|
+
_arity_walk(expr.left, known, profile, state)
|
|
2475
|
+
_arity_walk(expr.right, known, profile, state)
|
|
2476
|
+
elif isinstance(expr, S.Member):
|
|
2477
|
+
if not (isinstance(expr.base, S.Name) and expr.base.name.upper() in known):
|
|
2478
|
+
_arity_walk(expr.base, known, profile, state) # (`Tb.x`: a table is fine as the base)
|
|
2479
|
+
for a in expr.args or []:
|
|
2480
|
+
_arity_walk(a, known, profile, state)
|
|
2481
|
+
elif isinstance(expr, S.ArrayLiteral):
|
|
2482
|
+
for item in expr.items:
|
|
2483
|
+
_arity_walk(item, known, profile, state)
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
def order_names_around_arity(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2487
|
+
"""CR300/CR350: the parser reports undefined names as it meets them and the arity error of a
|
|
2488
|
+
built-in call last (oracle, 2026-10-03): `Sc = Nope3+Round(Nope1)` -> `Nope3`, `Nope1`, then
|
|
2489
|
+
`Expression expects , :`. A call with too many arguments stops the parse at its first surplus
|
|
2490
|
+
argument, so later names are not reported (`Floor(Nope1,Nope)` -> `Nope1` only,
|
|
2491
|
+
`Floor(Sd,Nope)+Nope3` -> none); too few arguments let the rest of the expression be parsed."""
|
|
2492
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2493
|
+
return
|
|
2494
|
+
per_line = collections.Counter((st.line, st.file) for st in program.statements)
|
|
2495
|
+
known = None
|
|
2496
|
+
for stmt in program.statements:
|
|
2497
|
+
if isinstance(stmt, S.Assignment):
|
|
2498
|
+
exprs = [stmt.target, stmt.value]
|
|
2499
|
+
elif isinstance(stmt, S.If):
|
|
2500
|
+
exprs = [stmt.cond]
|
|
2501
|
+
else:
|
|
2502
|
+
continue
|
|
2503
|
+
key = (stmt.line, stmt.file)
|
|
2504
|
+
if per_line[key] != 1:
|
|
2505
|
+
continue
|
|
2506
|
+
own = [(i, d) for i, d in enumerate(diags.items) if (d.line, d.file) == key and d.severity == "error"]
|
|
2507
|
+
if known is None:
|
|
2508
|
+
known = expressions._known_names(program, profile)
|
|
2509
|
+
state: _ArityState = {"names": []}
|
|
2510
|
+
for e in exprs:
|
|
2511
|
+
_arity_walk(e, known, profile, state)
|
|
2512
|
+
kind = state.get("kind")
|
|
2513
|
+
if state.get("bad") or kind is None:
|
|
2514
|
+
continue
|
|
2515
|
+
arity = [(i, d) for i, d in own if d.message.startswith(_ARITY_PREFIXES[kind])]
|
|
2516
|
+
if not arity and isinstance(stmt, S.Assignment) and own and expressions._undefined_names(stmt.target, known):
|
|
2517
|
+
# an unresolved target hides the pass-1 arity check, but the parse still reaches it
|
|
2518
|
+
arity = [
|
|
2519
|
+
(
|
|
2520
|
+
own[0][0],
|
|
2521
|
+
Diagnostic(
|
|
2522
|
+
"error",
|
|
2523
|
+
stmt.line,
|
|
2524
|
+
expressions._arity_message(kind, stmt.text, profile, stmt.at_eol, True),
|
|
2525
|
+
stmt.file,
|
|
2526
|
+
show_file=False,
|
|
2527
|
+
),
|
|
2528
|
+
)
|
|
2529
|
+
]
|
|
2530
|
+
if len(arity) != 1 or any(
|
|
2531
|
+
d.message != arity[0][1].message and not d.message.endswith(_UNRESOLVED_SUFFIX) for _, d in own
|
|
2532
|
+
):
|
|
2533
|
+
continue
|
|
2534
|
+
new = [
|
|
2535
|
+
Diagnostic("error", stmt.line, profile.unresolved_symbol_message.format(name=n), stmt.file)
|
|
2536
|
+
for n in state["names"]
|
|
2537
|
+
] + [arity[0][1]]
|
|
2538
|
+
first = own[0][0]
|
|
2539
|
+
drop = {id(d) for _, d in own}
|
|
2540
|
+
items = [d for d in diags.items if id(d) not in drop]
|
|
2541
|
+
at = first - sum(1 for i, d in enumerate(diags.items[:first]) if id(d) in drop)
|
|
2542
|
+
items[at:at] = new
|
|
2543
|
+
diags.items[:] = items
|
|
2544
|
+
|
|
2545
|
+
|
|
2546
|
+
def order_names_before_call_arity(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2547
|
+
"""CR300/CR350: an assignment that calls a user `Function` with the wrong argument count and
|
|
2548
|
+
also names undefined variables reports the names first, then the call's messages (oracle,
|
|
2549
|
+
2026-10-12: `A(Z) = F2(1,2,3)`, `Q = F2(1,2,3)`, `A(Z) = A(Y) + F2(1)`: `Z is not defined.`,
|
|
2550
|
+
`Y is not defined.`, then `Call argument required.` / the `No Matching` or fallback line)."""
|
|
2551
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2552
|
+
return
|
|
2553
|
+
functions = {st.name.upper() for st in program.statements if isinstance(st, S.SubDef) and st.keyword == "FUNCTION"}
|
|
2554
|
+
if not functions:
|
|
2555
|
+
return
|
|
2556
|
+
per_line = collections.Counter((st.line, st.file) for st in program.statements)
|
|
2557
|
+
for stmt in program.statements:
|
|
2558
|
+
if not isinstance(stmt, S.Assignment) or per_line[(stmt.line, stmt.file)] != 1:
|
|
2559
|
+
continue
|
|
2560
|
+
if not any(re.search(rf"(?i)\b{re.escape(f)}\s*\(", stmt.text or "") for f in functions):
|
|
2561
|
+
continue
|
|
2562
|
+
key = (stmt.line, stmt.file)
|
|
2563
|
+
at = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
|
|
2564
|
+
if not at or at != list(range(at[0], at[-1] + 1)):
|
|
2565
|
+
continue
|
|
2566
|
+
own = [diags.items[i] for i in at]
|
|
2567
|
+
names = [d for d in own if re.fullmatch(r"\w+ is not defined\.", d.message)]
|
|
2568
|
+
rest = [d for d in own if d not in names]
|
|
2569
|
+
if (
|
|
2570
|
+
not names
|
|
2571
|
+
or not rest
|
|
2572
|
+
or not all(
|
|
2573
|
+
d.message == "Call argument required."
|
|
2574
|
+
or d.message.startswith(
|
|
2575
|
+
("No Matching, or out of place ) in expression: ", "Invalid, or out of place expression: ")
|
|
2576
|
+
)
|
|
2577
|
+
for d in rest
|
|
2578
|
+
)
|
|
2579
|
+
):
|
|
2580
|
+
continue
|
|
2581
|
+
diags.items[at[0] : at[-1] + 1] = names + rest
|
|
2582
|
+
|
|
2583
|
+
|
|
2584
|
+
_CLOSER_ERRORS = frozenset(
|
|
2585
|
+
{
|
|
2586
|
+
"END IF without IF or END SELECT without SELECT.",
|
|
2587
|
+
"NEXT without FOR.",
|
|
2588
|
+
"LOOP without DO.",
|
|
2589
|
+
"IF or SELECT statement illegally has SCAN without NEXT SCAN.",
|
|
2590
|
+
"FOR NEXT statement illegally has SCAN without NEXT SCAN.",
|
|
2591
|
+
"LOOP statement illegally has SCAN without NEXT SCAN.",
|
|
2592
|
+
}
|
|
2593
|
+
)
|
|
2594
|
+
|
|
2595
|
+
|
|
2596
|
+
# Errors that do not hide the closer / `CallTable` errors below (oracle, CR300, 2026-10-04, the
|
|
2597
|
+
# fuzz reproducers `false_accept/ca5935f3a78b` and `a2f667cdbbc7`): another block-structure error
|
|
2598
|
+
# (`EndSub without Sub or nested subs.`) and a built-in call's arity error.
|
|
2599
|
+
def _hides_nothing(d) -> bool:
|
|
2600
|
+
if d.message.endswith(" must be used inside a table declaration."):
|
|
2601
|
+
return True # (oracle, 2026-10-13: `Sample` outside a table beside an unknown `CallTable`)
|
|
2602
|
+
if d.message.endswith("without Sub or nested subs."):
|
|
2603
|
+
return True
|
|
2604
|
+
if d.message == "If without EndIf inside of Sub.": # oracle, CR300/CR350, 2026-10-11 (`t5` probe)
|
|
2605
|
+
return True
|
|
2606
|
+
# a built-in call's arity error hides nothing only when a `Public.<name>` reference precedes
|
|
2607
|
+
# its first `)` on the line (oracle, 2026-10-04: `Round(Public.x,1,2)`, `Round(x,Public.T,2,3)`
|
|
2608
|
+
# hide nothing; `Round(x)+Public.T`, `FormatLong(x)`, `Floor(x,1)` hide the closers)
|
|
2609
|
+
if d.message.startswith(("No Matching, or out of place ) in expression", "Expression expects")):
|
|
2610
|
+
text = re.sub(r'"[^"]*"', lambda m: " " * len(m.group(0)), d.message.split(":", 1)[-1].lower())
|
|
2611
|
+
at = text.find("public.")
|
|
2612
|
+
return 0 <= at < (text.find(")") if ")" in text else len(text))
|
|
2613
|
+
return False
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
def drop_block_closer_errors_after_errors(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2617
|
+
"""CR300/CR350: the unmatched-closer errors (`END IF without IF ...`, `NEXT without FOR.`,
|
|
2618
|
+
`LOOP without DO.` and their `... illegally has SCAN without NEXT SCAN.` companions, with the
|
|
2619
|
+
`<closer> illegally occurs before BeginProg.` line) are only reported when no other error
|
|
2620
|
+
exists anywhere in the file, before or after them (oracle, CR300, 2026-10-03: `Sc = Nope1`
|
|
2621
|
+
then `EndIf`, `EndIf` then `Sc = Nope1`, a duplicate declaration, `Too many parameters`;
|
|
2622
|
+
several closer errors do not hide one another). CR6/CR1000X always report them."""
|
|
2623
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2624
|
+
return
|
|
2625
|
+
closers = [d for d in diags.items if d.severity == "error" and d.message in _CLOSER_ERRORS]
|
|
2626
|
+
if not closers:
|
|
2627
|
+
return
|
|
2628
|
+
lines = {(d.line, d.file) for d in closers}
|
|
2629
|
+
placement = [
|
|
2630
|
+
d
|
|
2631
|
+
for d in diags.items
|
|
2632
|
+
if d.severity == "error"
|
|
2633
|
+
and (d.line, d.file) in lines
|
|
2634
|
+
and d.message.endswith(" illegally occurs before BeginProg.")
|
|
2635
|
+
]
|
|
2636
|
+
ours = {id(d) for d in closers + placement}
|
|
2637
|
+
if not any(d.severity == "error" and id(d) not in ours and not _hides_nothing(d) for d in diags.items):
|
|
2638
|
+
return
|
|
2639
|
+
diags.items[:] = [d for d in diags.items if id(d) not in ours]
|
|
2640
|
+
|
|
2641
|
+
|
|
2642
|
+
def drop_calltable_error_with_other_errors(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2643
|
+
"""CR300/CR350: the `CallTable` (or table field) `Could not find DataTable %s.` is hidden by any other error
|
|
2644
|
+
found by pass 1 or tier B, before or after it (oracle, CR300, 2026-10-03: `CallTable Hourly`
|
|
2645
|
+
with `Sc = Nope1` before or after it, or a `Too many parameters`). The late checks run after
|
|
2646
|
+
it and print beside it (`SetSetting("Baudrate(9)",1)`, a `Matrix` option error; tests of
|
|
2647
|
+
`tests/fixtures/file_drives*.json`, `option_args.json`, `settings_fields.json`)."""
|
|
2648
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2649
|
+
return
|
|
2650
|
+
# (a table field in an expression is hidden the same way: oracle, CR300/CR350, 2026-10-11,
|
|
2651
|
+
# `x = Nope.Field(1,1)` with `Zork = 1` before or after it, `chm/tablenameeventend_1.CR6`)
|
|
2652
|
+
sites = {(st.line, st.file) for st in program.statements if not isinstance(st, S.Unparsed)}
|
|
2653
|
+
mine = [
|
|
2654
|
+
d
|
|
2655
|
+
for d in diags.items
|
|
2656
|
+
if d.severity == "error"
|
|
2657
|
+
and (d.line, d.file) in sites
|
|
2658
|
+
and d.message.startswith(
|
|
2659
|
+
("Could not find DataTable ", "Could not find FileMark table ", "Cannot find DataTable ")
|
|
2660
|
+
)
|
|
2661
|
+
]
|
|
2662
|
+
lines = {(d.line, d.file) for d in mine} # (an error on the same line does not hide it: oracle,
|
|
2663
|
+
# CR300/CR350, 2026-10-11, `X = Tmp.dat + Zed` prints both)
|
|
2664
|
+
if mine and any(
|
|
2665
|
+
d.severity == "error" and d not in mine and not _hides_nothing(d) and (d.line, d.file) not in lines
|
|
2666
|
+
for d in diags.items
|
|
2667
|
+
):
|
|
2668
|
+
diags.items[:] = [d for d in diags.items if d not in mine]
|
|
2669
|
+
|
|
2670
|
+
|
|
2671
|
+
def drop_compound_target_repeat(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2672
|
+
"""CR300/CR350: `Nope += 1` reports `Nope is not defined.` once, though it is stored as
|
|
2673
|
+
`Nope = Nope + 1` (oracle, 2026-10-01; also under the cascade, `i2copen_1.CR6`)."""
|
|
2674
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2675
|
+
return
|
|
2676
|
+
for stmt in program.statements:
|
|
2677
|
+
if not (isinstance(stmt, S.Assignment) and stmt.compound and isinstance(stmt.target, (S.Name, S.Index))):
|
|
2678
|
+
continue
|
|
2679
|
+
message = profile.unresolved_symbol_message.format(name=stmt.target.name)
|
|
2680
|
+
same = [
|
|
2681
|
+
i for i, d in enumerate(diags.items) if (d.line, d.file) == (stmt.line, stmt.file) and d.message == message
|
|
2682
|
+
]
|
|
2683
|
+
if len(same) >= 2:
|
|
2684
|
+
del diags.items[same[1]]
|
|
2685
|
+
|
|
2686
|
+
|
|
2687
|
+
_CALL_KEYWORD_RE = re.compile(r"\b(?:CASE|DO|FOR|FUNCTION|SCAN|SELECT|SUB|SUBSCAN|WHILE)\s*\(", re.I)
|
|
2688
|
+
|
|
2689
|
+
|
|
2690
|
+
def order_block_keyword_calls(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2691
|
+
"""CR300/CR350: a block keyword used as a function (`Y = Scan(1)`) ends the statement's name
|
|
2692
|
+
resolution at that call. The names before it are reported (`Q = Scan(1)`, `Y = Q + Scan(1)`,
|
|
2693
|
+
`If Q = 1 Then Y = Scan(1)`: `Q is not defined.`) ahead of its `Incomplete expression`;
|
|
2694
|
+
names inside or after it are not (`Y = Scan(Q)`, `Y = Scan(1) + Q`, `Y = Scan(Zork(1))`, no
|
|
2695
|
+
`Dimensions not declared` either). An indexed target that is not a declared array is the
|
|
2696
|
+
generic `Invalid, or out of place expression:` instead, with no `Incomplete`. Oracle, CR300/CR350, 2026-10-10."""
|
|
2697
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2698
|
+
return
|
|
2699
|
+
for stmt in program.statements:
|
|
2700
|
+
if not isinstance(stmt, (S.Assignment, S.If, S.ElseIf)):
|
|
2701
|
+
continue
|
|
2702
|
+
key = (stmt.line, stmt.file)
|
|
2703
|
+
# string literals are not code: `LEN("Scan(")` is no call, `LEN("Nope")` names nothing
|
|
2704
|
+
code = re.sub(r'"[^"]*"', lambda q: " " * len(q.group()), stmt.text or "")
|
|
2705
|
+
m = _CALL_KEYWORD_RE.search(code)
|
|
2706
|
+
marks = [d for d in diags.items if (d.line, d.file) == key and d.message.startswith("Incomplete expression:")]
|
|
2707
|
+
if not (m and marks):
|
|
2708
|
+
continue
|
|
2709
|
+
if any(
|
|
2710
|
+
(d.line, d.file) == key and d.message.startswith("Invalid, or out of place expression:")
|
|
2711
|
+
for d in diags.items
|
|
2712
|
+
): # `Q(1) = Scan(1)`, an undeclared or scalar indexed target
|
|
2713
|
+
diags.items[:] = [d for d in diags.items if d not in marks]
|
|
2714
|
+
continue
|
|
2715
|
+
before = {w.upper() for w in re.findall(r"[A-Za-z_]\w*", code[: m.start()])}
|
|
2716
|
+
keep = []
|
|
2717
|
+
for d in diags.items:
|
|
2718
|
+
if (d.line, d.file) == key:
|
|
2719
|
+
name = re.fullmatch(r"(\w+) is not defined\.", d.message)
|
|
2720
|
+
if d.message.startswith("Dimensions not declared") or (name and name.group(1).upper() not in before):
|
|
2721
|
+
continue
|
|
2722
|
+
keep.append(d)
|
|
2723
|
+
group = [d for d in keep if (d.line, d.file) == key]
|
|
2724
|
+
group = [d for d in group if d not in marks] + [d for d in group if d in marks]
|
|
2725
|
+
first = next(i for i, d in enumerate(keep) if (d.line, d.file) == key)
|
|
2726
|
+
rest = [d for d in keep if (d.line, d.file) != key]
|
|
2727
|
+
rest[first:first] = group # (`first` counts only earlier other-line items: all kept)
|
|
2728
|
+
diags.items[:] = rest
|
|
2729
|
+
|
|
2730
|
+
|
|
2731
|
+
def fix_then_operand(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2732
|
+
"""CR300/CR350: an `If` whose condition runs into its `Then` (`If X = Then`, `If X + Then`) is
|
|
2733
|
+
the condition's own syntax error, not `Then is not defined.` (oracle, 2026-10-10; the same
|
|
2734
|
+
message CR6/CR1000X give from `structure._check_if_pass1`)."""
|
|
2735
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2736
|
+
return
|
|
2737
|
+
for stmt in program.statements:
|
|
2738
|
+
if not (isinstance(stmt, (S.If, S.ElseIf)) and structure._mentions_then(stmt.cond)):
|
|
2739
|
+
continue
|
|
2740
|
+
verdict = structure._pass1_scan(stmt.text, structure._Everything(), profile)
|
|
2741
|
+
if not (verdict and verdict[0] == "pass1"):
|
|
2742
|
+
continue
|
|
2743
|
+
key = (stmt.line, stmt.file)
|
|
2744
|
+
hits = [
|
|
2745
|
+
i
|
|
2746
|
+
for i, d in enumerate(diags.items)
|
|
2747
|
+
if (d.line, d.file) == key and d.message == profile.unresolved_symbol_message.format(name="Then")
|
|
2748
|
+
]
|
|
2749
|
+
if not hits:
|
|
2750
|
+
continue
|
|
2751
|
+
message = structure._pass1_message(profile, dataclasses.replace(stmt, at_eol=False), verdict[1])
|
|
2752
|
+
diags.items[hits[0]] = dataclasses.replace(diags.items[hits[0]], message=message)
|
|
2753
|
+
for i in reversed(hits[1:]):
|
|
2754
|
+
del diags.items[i]
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
def order_invalid_assignments(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2758
|
+
"""CR300/CR350: the undefined names of an assignment come before its `Invalid assignment in
|
|
2759
|
+
expression:` line (oracle, 2026-10-10: `Sec = Nope`, `6 = estimated`), which pass 1 emitted first."""
|
|
2760
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2761
|
+
return
|
|
2762
|
+
for key in {(d.line, d.file) for d in diags.items if d.message.startswith("Invalid assignment in expression:")}:
|
|
2763
|
+
own = [d for d in diags.items if (d.line, d.file) == key]
|
|
2764
|
+
marks = [d for d in own if d.message.startswith("Invalid assignment in expression:")]
|
|
2765
|
+
group = [d for d in own if d not in marks] + marks
|
|
2766
|
+
first = next(i for i, d in enumerate(diags.items) if (d.line, d.file) == key)
|
|
2767
|
+
rest = [d for d in diags.items if (d.line, d.file) != key]
|
|
2768
|
+
rest[first:first] = group
|
|
2769
|
+
diags.items[:] = rest
|
|
2770
|
+
|
|
2771
|
+
|
|
2772
|
+
_STRING_END_QUOTE = "String without end quote"
|
|
2773
|
+
_IF_STRING_RE = re.compile(r"(?i)\s*If\s+(.*?)\s+Then\s+([A-Za-z_]\w*)\s*(?:\([^()]*\))?\s*=")
|
|
2774
|
+
|
|
2775
|
+
|
|
2776
|
+
_BINARY_SYMBOLS = frozenset({"+", "-", "*", "/", "^", "&", "<", ">", "=", "<>", "<=", ">=", "\\"})
|
|
2777
|
+
_WORD_OPERATORS = frozenset({"AND", "OR", "XOR", "EQV", "IMP", "MOD", "IS", "TO"})
|
|
2778
|
+
|
|
2779
|
+
|
|
2780
|
+
def _string_reached(text: str) -> bool:
|
|
2781
|
+
"""True when the tokens before an unterminated string are a well-formed expression prefix, so
|
|
2782
|
+
the lexer's `String without end quote` is met (oracle, CR300/CR350, 2026-10-11: `Foo" bar`,
|
|
2783
|
+
`Nope + "abc`, `Len("abc` reach it, `Datalogger</title>")` fails at the `/` first)."""
|
|
2784
|
+
try:
|
|
2785
|
+
lexer.tokenize(text, 0)
|
|
2786
|
+
return False
|
|
2787
|
+
except lexer.LexError as e:
|
|
2788
|
+
if e.message != "unterminated string" or text[e.col : e.col + 1] != '"':
|
|
2789
|
+
return False
|
|
2790
|
+
head = text[: e.col]
|
|
2791
|
+
toks = [t for t in lexer.tokenize(head, 0) if t.kind != "EOF"]
|
|
2792
|
+
expect, depth = True, 0
|
|
2793
|
+
for t in toks:
|
|
2794
|
+
word_op = t.kind == "IDENT" and t.upper in _WORD_OPERATORS
|
|
2795
|
+
if expect:
|
|
2796
|
+
if t.kind == "OP" and t.text in ("(", "-", "+"):
|
|
2797
|
+
depth += t.text == "("
|
|
2798
|
+
elif t.kind == "IDENT" and t.upper == "NOT":
|
|
2799
|
+
pass
|
|
2800
|
+
elif t.kind in ("IDENT", "NUMBER", "STRING") and not word_op:
|
|
2801
|
+
expect = False
|
|
2802
|
+
else:
|
|
2803
|
+
return False
|
|
2804
|
+
elif (t.kind == "OP" and t.text in _BINARY_SYMBOLS) or word_op:
|
|
2805
|
+
expect = True
|
|
2806
|
+
elif t.kind == "OP" and t.text == "(":
|
|
2807
|
+
depth += 1
|
|
2808
|
+
expect = True
|
|
2809
|
+
elif t.kind == "OP" and t.text == ")":
|
|
2810
|
+
depth -= 1
|
|
2811
|
+
elif t.kind == "OP" and t.text == "," and depth > 0:
|
|
2812
|
+
expect = True
|
|
2813
|
+
else:
|
|
2814
|
+
return False # a juxtaposed operand or anything else before the string: not modelled
|
|
2815
|
+
return True
|
|
2816
|
+
|
|
2817
|
+
|
|
2818
|
+
def order_unterminated_string(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2819
|
+
"""CR300/CR350: a statement cut short by an unterminated string prints its undefined names,
|
|
2820
|
+
then `String without end quote`, then its final message (oracle, CR300 and CR350, 2026-10-11:
|
|
2821
|
+
`Foo" bar`, `x + "abc`, `Len("abc`, `Nope = "abc`, `If Nope Then Nope2 = "abc`; the final
|
|
2822
|
+
message was already right for most shapes). A call of a built-in function that the string leaves
|
|
2823
|
+
open is the generic fallback, not `No Matching` (`SprintF ( s, "The ...`). Left alone: a line
|
|
2824
|
+
that already has an instruction/call message (`Battery("abc`), and a declared variable with a
|
|
2825
|
+
subscript before the string (`x(1) "abc`, no string line)."""
|
|
2826
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2827
|
+
return
|
|
2828
|
+
known = None
|
|
2829
|
+
declared = symbols.declared_variable_names(program)
|
|
2830
|
+
for stmt in program.statements:
|
|
2831
|
+
if not isinstance(stmt, S.Unparsed) or stmt.reason != "lex error: unterminated string":
|
|
2832
|
+
continue
|
|
2833
|
+
key = (stmt.line, stmt.file)
|
|
2834
|
+
own = [d for d in diags.items if (d.line, d.file) == key]
|
|
2835
|
+
final = next(
|
|
2836
|
+
(
|
|
2837
|
+
d
|
|
2838
|
+
for d in own
|
|
2839
|
+
if d.message.startswith(
|
|
2840
|
+
(_GENERIC_FALLBACK_PREFIX, "Incomplete expression:", "No Matching, or out of place )")
|
|
2841
|
+
)
|
|
2842
|
+
),
|
|
2843
|
+
None,
|
|
2844
|
+
)
|
|
2845
|
+
if final is None or not all(
|
|
2846
|
+
d.message.endswith(_UNRESOLVED_SUFFIX) or d.message == _STRING_END_QUOTE or d is final for d in own
|
|
2847
|
+
):
|
|
2848
|
+
continue
|
|
2849
|
+
sub = re.match(r"\s*([A-Za-z_]\w*)\s*\([^()\"]*\)\s+\"", stmt.text)
|
|
2850
|
+
if (sub and sub.group(1).upper() in declared) or re.match(r"(?i)\s*(?:Public|Dim)\b", stmt.text):
|
|
2851
|
+
continue
|
|
2852
|
+
if not _IF_STRING_RE.match(stmt.text) and not _string_reached(stmt.text):
|
|
2853
|
+
continue
|
|
2854
|
+
names = [d for d in own if d.message.endswith(_UNRESOLVED_SUFFIX)]
|
|
2855
|
+
if not names and (m := _IF_STRING_RE.match(stmt.text)):
|
|
2856
|
+
if known is None:
|
|
2857
|
+
known = prose_known(program, profile)
|
|
2858
|
+
here = known - declared_later(first_declarations(program), stmt.line, stmt.file) - diags.cascade_gone
|
|
2859
|
+
words = _leading_unknown_words(m.group(1), here) + ([m.group(2)] if m.group(2).upper() not in here else [])
|
|
2860
|
+
names = [
|
|
2861
|
+
Diagnostic("error", stmt.line, profile.unresolved_symbol_message.format(name=w), stmt.file, True)
|
|
2862
|
+
for w in words
|
|
2863
|
+
]
|
|
2864
|
+
if (
|
|
2865
|
+
final.message.startswith("No Matching")
|
|
2866
|
+
and re.match(r"\s*([A-Za-z_]\w*)\s*\(", stmt.text)
|
|
2867
|
+
and stmt.text.lstrip().split("(")[0].strip().upper() in profile.functions
|
|
2868
|
+
):
|
|
2869
|
+
final = dataclasses.replace(
|
|
2870
|
+
final,
|
|
2871
|
+
message=f"{_GENERIC_FALLBACK_PREFIX}{stmt.text}"
|
|
2872
|
+
f"{chr(10) if final.message.endswith(chr(10) + '.') else ''}.",
|
|
2873
|
+
)
|
|
2874
|
+
at = diags.items.index(own[0])
|
|
2875
|
+
diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
|
|
2876
|
+
diags.items[at:at] = [*names, Diagnostic("error", stmt.line, _STRING_END_QUOTE, stmt.file, True), final]
|
|
2877
|
+
|
|
2878
|
+
|
|
2879
|
+
def semicolon_headers(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2880
|
+
"""CR300/CR350: a `;` in a `For` header or `Const` value is one `Invalid symbol in expression:
|
|
2881
|
+
<value> ;.` that replaces everything else reported for the line; the echo runs from the value
|
|
2882
|
+
that holds the first `;` (after `=`, `To` or `Step`) to the next `To`/`Step` word or the end of the
|
|
2883
|
+
statement, the blank before that word excluded (oracle, 2026-10-12:
|
|
2884
|
+
`For Y = 1 To 3 Step 1 ;` -> `1 ;`, `For Y = 1 ; To 3` -> ` 1 ;`, `Const K = ; 1` -> `; 1`)."""
|
|
2885
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2886
|
+
return
|
|
2887
|
+
for stmt in program.statements:
|
|
2888
|
+
if not isinstance(stmt, S.Unparsed) or ";" not in stmt.text:
|
|
2889
|
+
continue
|
|
2890
|
+
m = re.match(r"\s*(FOR|CONST)\b", stmt.text, re.I)
|
|
2891
|
+
if not m:
|
|
2892
|
+
continue
|
|
2893
|
+
try:
|
|
2894
|
+
toks = [t for t in lexer.tokenize(stmt.text, 0) if t.kind != "EOF"]
|
|
2895
|
+
except lexer.LexError:
|
|
2896
|
+
continue
|
|
2897
|
+
semi = next((i for i, t in enumerate(toks) if t.kind == "OP" and t.text == ";"), None)
|
|
2898
|
+
if semi is None or semi < 2:
|
|
2899
|
+
continue
|
|
2900
|
+
if m.group(1).upper() == "CONST":
|
|
2901
|
+
eq = next((t for t in toks if t.kind == "OP" and t.text == "="), None)
|
|
2902
|
+
if eq is None:
|
|
2903
|
+
continue
|
|
2904
|
+
echo = stmt.text[(toks[semi].col if toks[semi].col < eq.col else eq.col + 1) :].lstrip()
|
|
2905
|
+
else:
|
|
2906
|
+
start = None
|
|
2907
|
+
for t in toks[:semi]:
|
|
2908
|
+
if t.kind == "OP" and t.text == "=":
|
|
2909
|
+
start = t.col + 1
|
|
2910
|
+
elif t.kind == "IDENT" and t.upper in ("TO", "STEP"):
|
|
2911
|
+
rest = stmt.text[t.col + len(t.text) :]
|
|
2912
|
+
start = t.col + len(t.text) + len(rest) - len(rest.lstrip())
|
|
2913
|
+
if start is None:
|
|
2914
|
+
continue
|
|
2915
|
+
stop = next((t for t in toks[semi:] if t.kind == "IDENT" and t.upper in ("TO", "STEP")), None)
|
|
2916
|
+
end = len(stmt.text) if stop is None else stop.col - (stop.col > 0 and stmt.text[stop.col - 1] in " \t")
|
|
2917
|
+
echo = stmt.text[start:end]
|
|
2918
|
+
key = (stmt.line, stmt.file)
|
|
2919
|
+
at = next((i for i, d in enumerate(diags.items) if (d.line, d.file) == key), None)
|
|
2920
|
+
if at is None:
|
|
2921
|
+
continue
|
|
2922
|
+
diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
|
|
2923
|
+
diags.items.insert(
|
|
2924
|
+
at, Diagnostic("error", stmt.line, f"Invalid symbol in expression: {echo}.", stmt.file, True)
|
|
2925
|
+
)
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
def add_string_illegal(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2929
|
+
"""CR300/CR350: an unparsable statement that starts with a well-formed expression holding a
|
|
2930
|
+
String operand of a numeric/logical operator prints `String is illegal here` before its final
|
|
2931
|
+
message, as on CR6/CR1000X (`structure._unparsed_string_messages`; oracle, CR300/CR350,
|
|
2932
|
+
2026-10-11: `"BattV" and "Counter".`, `x = "BattV" and "Counter".`; a lone string or `x "a" and
|
|
2933
|
+
"b"` print none)."""
|
|
2934
|
+
if profile.name not in ("cr300", "cr350"):
|
|
2935
|
+
return
|
|
2936
|
+
for stmt in program.statements:
|
|
2937
|
+
if not isinstance(stmt, S.Unparsed) or stmt.reason == "lex error: unterminated string":
|
|
2938
|
+
continue
|
|
2939
|
+
key = (stmt.line, stmt.file)
|
|
2940
|
+
own = [d for d in diags.items if (d.line, d.file) == key]
|
|
2941
|
+
prefixes = (_GENERIC_FALLBACK_PREFIX, "Incomplete expression:") + (
|
|
2942
|
+
("Invalid symbol in expression:",) if ";" in stmt.text else ()
|
|
2943
|
+
) # `X = "a" and "b" ;`
|
|
2944
|
+
final = next((d for d in own if d.message.startswith(prefixes)), None)
|
|
2945
|
+
if final is None or any(d.message == "String is illegal here" for d in own):
|
|
2946
|
+
continue
|
|
2947
|
+
messages = structure._unparsed_string_messages(stmt, program, profile)
|
|
2948
|
+
if messages:
|
|
2949
|
+
at = diags.items.index(final)
|
|
2950
|
+
diags.items[at:at] = [Diagnostic("error", stmt.line, m, stmt.file, True) for m in messages]
|
|
2951
|
+
_order_arith_string_lines(diags)
|
|
2952
|
+
|
|
2953
|
+
|
|
2954
|
+
_ARITH_STRING_MESSAGES = ("Illegal String operand", "Illegal operand of type STRING")
|
|
2955
|
+
|
|
2956
|
+
|
|
2957
|
+
def _order_arith_string_lines(diags: Diagnostics) -> None:
|
|
2958
|
+
"""CR300/CR350: the arithmetic String errors (`Illegal String operand`) come before the
|
|
2959
|
+
statement's `X is not defined.` lines, the logical `String is illegal here` after them (oracle,
|
|
2960
|
+
CR300, 2026-10-12: `"a" * 2 foo`, `S * 2 foo`, `"a" MOD 2 foo` vs `X AND "a" foo`)."""
|
|
2961
|
+
items = diags.items
|
|
2962
|
+
strings = (*_ARITH_STRING_MESSAGES, "String is illegal here")
|
|
2963
|
+
for key in {(d.line, d.file) for d in items if d.message in strings}:
|
|
2964
|
+
slots = [i for i, d in enumerate(items) if (d.line, d.file) == key]
|
|
2965
|
+
own = [items[i] for i in slots]
|
|
2966
|
+
arith = [d for d in own if d.message in _ARITH_STRING_MESSAGES]
|
|
2967
|
+
logical = [d for d in own if d.message == "String is illegal here"]
|
|
2968
|
+
names = [d for d in own if d.message.endswith(" is not defined.")]
|
|
2969
|
+
if not names or (arith and logical):
|
|
2970
|
+
continue
|
|
2971
|
+
rest = [d for d in own if d not in arith and d not in logical and d not in names]
|
|
2972
|
+
order = arith + names + logical + rest
|
|
2973
|
+
if order != own:
|
|
2974
|
+
for i, d in zip(slots, order):
|
|
2975
|
+
items[i] = d
|
|
2976
|
+
|
|
2977
|
+
|
|
2978
|
+
_TCPOPEN_HANDLE = "Parameter must be a variable of type long"
|
|
2979
|
+
|
|
2980
|
+
|
|
2981
|
+
def check_tcpopen_handle_early(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2982
|
+
"""The handle error is a `Too many parameters` cascade trigger (oracle, CR300/CR350,
|
|
2983
|
+
2026-10-12: a bad handle inside a `Sub` makes the following `Scan (1,Sec,3,0)` and `Delay(1,..)`
|
|
2984
|
+
print `Too many parameters: Sec.` / `1.`), so it is added before `check_too_many_cascade`;
|
|
2985
|
+
`check_tcpopen_handle` re-adds it afterwards on a line the cascade rewrote."""
|
|
2986
|
+
check_tcpopen_handle(program, profile, diags, early=True)
|
|
2987
|
+
|
|
2988
|
+
|
|
2989
|
+
def check_tcpopen_handle(program: S.Program, profile, diags: Diagnostics, early: bool = False) -> None:
|
|
2990
|
+
"""CR300/CR350: `TCPOpen`'s fifth argument (the connection handle) must be a Long or Boolean
|
|
2991
|
+
variable or array (a `Boolean` is a 4-byte integer: oracle, CR300/CR350, 2026-10-12, `Public
|
|
2992
|
+
Sock As Boolean`, `Sock(3) As Boolean`, `H As Boolean` Sub parameter compile), including a
|
|
2993
|
+
`Sub` parameter (`H As Long` / `H As Boolean`; an untyped, `As Float`, `As String` or defaulted
|
|
2994
|
+
untyped `Optional H = 1` parameter is bad: same date): a Float or String variable, a constant, an expression or an undefined name (a
|
|
2995
|
+
declaration the cascade dropped too) is `Parameter must be a variable of type long` (oracle,
|
|
2996
|
+
CR300, CR350 and CR6, 2026-10-11, `chm/tcpopen_4.CR6`), printed after the statement's
|
|
2997
|
+
`X is not defined.` lines and before `Dimensions not declared`. CR6/CR1000X not done: their
|
|
2998
|
+
line comes first, with the connection count check."""
|
|
2999
|
+
if profile.name not in ("cr300", "cr350"):
|
|
3000
|
+
return
|
|
3001
|
+
types = symbols.declared_variable_types(program)
|
|
3002
|
+
known = expressions._known_names(program, profile)
|
|
3003
|
+
gone = diags.cascade_gone
|
|
3004
|
+
params: dict[str, str | None] = {} # the enclosing Sub/Function's plain parameters: name -> type
|
|
3005
|
+
for stmt in program.statements:
|
|
3006
|
+
if isinstance(stmt, S.SubDef):
|
|
3007
|
+
params = {
|
|
3008
|
+
p.name.upper(): (
|
|
3009
|
+
(p.type or "FLOAT").upper()
|
|
3010
|
+
if p.by_ref is None
|
|
3011
|
+
and not p.dims
|
|
3012
|
+
and (p.type or "FLOAT").upper() in ("LONG", "BOOLEAN", "FLOAT", "STRING")
|
|
3013
|
+
else None
|
|
3014
|
+
)
|
|
3015
|
+
for p in stmt.params
|
|
3016
|
+
}
|
|
3017
|
+
continue
|
|
3018
|
+
if bindings.closes_routine(stmt):
|
|
3019
|
+
params = {}
|
|
3020
|
+
continue
|
|
3021
|
+
if isinstance(stmt, S.Call) and stmt.name.upper() == "TCPOPEN":
|
|
3022
|
+
calls = [stmt.args]
|
|
3023
|
+
else:
|
|
3024
|
+
calls = [
|
|
3025
|
+
ix.args
|
|
3026
|
+
for e in expressions._stmt_exprs(stmt)
|
|
3027
|
+
for ix in expressions._index_nodes(e)
|
|
3028
|
+
if ix.name.upper() == "TCPOPEN"
|
|
3029
|
+
]
|
|
3030
|
+
for args in calls:
|
|
3031
|
+
if len(args) < 5 or args[4] is None:
|
|
3032
|
+
continue
|
|
3033
|
+
arg = args[4]
|
|
3034
|
+
own_dims = any(
|
|
3035
|
+
(d.line, d.file) == (stmt.line, stmt.file) and d.message.startswith("Dimensions not declared")
|
|
3036
|
+
for d in diags.items
|
|
3037
|
+
)
|
|
3038
|
+
if own_dims and not (isinstance(args[4], S.Index) and args[4].name.upper() not in known):
|
|
3039
|
+
continue # (an earlier argument's dimension error ends the walk before the fifth)
|
|
3040
|
+
if isinstance(args[3], (S.Name, S.Index)) and args[3].name.upper() not in known:
|
|
3041
|
+
continue # (a non-constant fourth argument ends the list: `Parameter must be a constant integer`, not done)
|
|
3042
|
+
if isinstance(arg, (S.Name, S.Index)):
|
|
3043
|
+
up = arg.name.upper()
|
|
3044
|
+
if up in params:
|
|
3045
|
+
if params[up] is None:
|
|
3046
|
+
continue # (a ByVal/ByRef, array or unusual-type parameter: not probed)
|
|
3047
|
+
bad = params[up] not in ("LONG", "BOOLEAN")
|
|
3048
|
+
elif up in gone or up not in known:
|
|
3049
|
+
bad = True
|
|
3050
|
+
elif up in types:
|
|
3051
|
+
bad = types[up] not in ("LONG", "BOOLEAN")
|
|
3052
|
+
else:
|
|
3053
|
+
continue # (an alias or a constant: not probed)
|
|
3054
|
+
else:
|
|
3055
|
+
bad = isinstance(arg, (S.Number, S.String, S.Binary, S.Unary))
|
|
3056
|
+
if not bad:
|
|
3057
|
+
continue
|
|
3058
|
+
key = (stmt.line, stmt.file)
|
|
3059
|
+
own = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
|
|
3060
|
+
if any(diags.items[i].message == _TCPOPEN_HANDLE for i in own) and not early:
|
|
3061
|
+
continue
|
|
3062
|
+
names = [i for i in own if diags.items[i].message.endswith(_UNRESOLVED_SUFFIX)]
|
|
3063
|
+
at = (names[-1] + 1) if names else (own[0] if own else len(diags.items))
|
|
3064
|
+
diags.items.insert(at, Diagnostic("error", stmt.line, _TCPOPEN_HANDLE, stmt.file, True))
|
|
3065
|
+
|
|
3066
|
+
|
|
3067
|
+
def order_target_name_first(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
3068
|
+
"""CR300/CR350: `Socket = TCPOpen` with `Socket` undeclared names the target before the
|
|
3069
|
+
`Expression expects (` of the bare function on the right (oracle, 2026-10-10: comportisactive_1,
|
|
3070
|
+
ethernetpower_1), though pass 1 emitted that one first."""
|
|
3071
|
+
if profile.name not in ("cr300", "cr350"):
|
|
3072
|
+
return
|
|
3073
|
+
for stmt in program.statements:
|
|
3074
|
+
if not (isinstance(stmt, S.Assignment) and isinstance(stmt.target, S.Name)):
|
|
3075
|
+
continue
|
|
3076
|
+
key = (stmt.line, stmt.file)
|
|
3077
|
+
message = profile.unresolved_symbol_message.format(name=stmt.target.name)
|
|
3078
|
+
own = [d for d in diags.items if (d.line, d.file) == key]
|
|
3079
|
+
mark = next((d for d in own if d.message == message), None)
|
|
3080
|
+
if mark is None or own[0] is mark or not any(d.message.startswith("Expression expects (") for d in own):
|
|
3081
|
+
continue
|
|
3082
|
+
first = next(i for i, d in enumerate(diags.items) if (d.line, d.file) == key)
|
|
3083
|
+
diags.items.remove(mark)
|
|
3084
|
+
diags.items.insert(first, mark)
|
|
3085
|
+
|
|
3086
|
+
|
|
3087
|
+
def drop_after_alias_collision(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
3088
|
+
"""CR300/CR350: after an `Alias` name reports `%s already declared as a variable.` (global or
|
|
3089
|
+
in a routine), no later line reports anything; the rest of that statement still does (oracle,
|
|
3090
|
+
2026-10-23, `tests/fixtures/r31_alias_collisions.json`; a `Const` or predefined collision does
|
|
3091
|
+
not do this). "Later" is in expanded source order, across `Include` files (round 31 review)."""
|
|
3092
|
+
if not profile.single_pass:
|
|
3093
|
+
return
|
|
3094
|
+
aliases = {
|
|
3095
|
+
(st.line, st.file)
|
|
3096
|
+
for st in program.statements
|
|
3097
|
+
if isinstance(st, S.Instruction) and st.keyword.upper() == "ALIAS"
|
|
3098
|
+
}
|
|
3099
|
+
hit = next(
|
|
3100
|
+
(
|
|
3101
|
+
d
|
|
3102
|
+
for d in diags.items
|
|
3103
|
+
if d.severity == "error"
|
|
3104
|
+
and (d.line, d.file) in aliases
|
|
3105
|
+
and d.message.endswith(" already declared as a variable.")
|
|
3106
|
+
),
|
|
3107
|
+
None,
|
|
3108
|
+
)
|
|
3109
|
+
if hit is not None:
|
|
3110
|
+
order: dict[tuple, int] = {}
|
|
3111
|
+
for i, st in enumerate(program.statements):
|
|
3112
|
+
order.setdefault((st.line, st.file), i)
|
|
3113
|
+
at = order[(hit.line, hit.file)]
|
|
3114
|
+
diags.items[:] = [
|
|
3115
|
+
d
|
|
3116
|
+
for d in diags.items
|
|
3117
|
+
if not (order.get((d.line, d.file), -1) > at or (d.file == hit.file and d.line > hit.line))
|
|
3118
|
+
]
|