opencrbasic 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,249 @@
1
+ """Per-logger analog-channel and port terminal model.
2
+
3
+ Generalises the analog-channel range check (originally CR6-only, hard-coded `U1..U12`) across all
4
+ four loggers, driven by oracle-probed per-logger data (`data/<logger>/terminals.json`; evidence,
5
+ probes and the message-text differences: `docs/loggers.md`, `docs/instructions.md`).
6
+
7
+ Each logger's `terminals.json` names its own channel "kinds" -- CR6 has separate `u_terminal`
8
+ (single-ended-shaped: any `U1..U12`, no oddness constraint; `ACPower`, `CS616`'s `CS616Chan`,
9
+ `ExciteV`/`ExciteI`'s `ExChan`), `u_terminal_diff` (`VoltDiff`/`TCDiff`'s `DiffChan`: `U1..U12` only
10
+ on an *odd* terminal, a second, distinct message when the value is otherwise in range) and
11
+ `u_terminal_se` (`VoltSe`/`TCSe`'s `SEChan`: any `U1..U12`, no oddness) -- both of the latter two
12
+ also allow a burst-mode negative value (`transform: "abs"`); CR1000X and CR300/CR350 instead use
13
+ `diff_chan`/`se_chan`, a plain integer with a different valid range and message
14
+ (`docs/loggers.md`). A `"port"`-shaped kind (`port_c1c4_u1u12` on CR6, `port_c1c8` on CR1000X) is a
15
+ disjoint union of ranges (`PortSet`/`PortGet`'s `Port`, `CS616`'s `CS616Port`); `sdi_port` (CR6) /
16
+ `sdi_port_cr1000x` (CR1000X) is a disjoint union restricted to *odd* terminals only (SDI-12's own
17
+ half-duplex wiring, `docs/instructions.md`). `checker/instructions.py` calls `check` here; the two
18
+ modules stay separate because this one owns the per-logger *data*, while `instructions.py` is the
19
+ shared entry point another workstream also uses.
20
+
21
+ A `kind` dict:
22
+ - `ranges`: list of `[min, max]` pairs (a single-range kind may instead give `min`/`max` directly,
23
+ kept for the pre-existing CR1000X/CR300/CR350 `diff_chan`/`se_chan` entries).
24
+ - `transform`: `"abs"` (test/show `abs(value)`, burst-mode) or `"none"` (default).
25
+ - `skip_nonpositive`: CR300/CR350 (`docs/loggers.md`) -- a value `<= 0` is deliberately left
26
+ unchecked (real, different, not-yet-modelled validation shape there; never guessed at).
27
+ - `odd_only` + `odd_message`: after the range check passes, the (transformed) value must also be
28
+ odd, else `odd_message` fires instead of `message` (`docs/instructions.md`'s "Invalid start
29
+ channel for Differential Measurement..." / "Invalid SDI-12 port..." findings).
30
+ - `allowed` + `allowed_message`: after the range check passes, the value must also be one of
31
+ `allowed` (`SPIOpen`'s `C1, U1, U5, or U9`, `TimerInput`'s starting channel).
32
+ - `secondary_when_invalid`: CR1000X -- an out-of-range value also gets the `odd_message`/
33
+ `allowed_message` line after `message` (its port converter returns 0 for a bad port, which
34
+ then fails the instruction's own test; CR6 stops at `message`).
35
+ - `message`: the range-check failure text, `{v}` = the displayed (transformed) value.
36
+
37
+ CR6 ports go through one converter (decompiled `FUN_00515ff0`): `C1..C16` (101..116) and
38
+ `U1..U12` (201..212) are valid, `C5..C16` being the same terminals as `U1..U12` (oracle,
39
+ 2026-10-01: `PortSet(109,1)`, `SPIOpen(105,...)` compile); CR1000X takes `C1..C8`.
40
+ """
41
+
42
+ import math
43
+
44
+ from .. import consteval
45
+ from .. import syntax as S
46
+ from ..diagnostics import Diagnostics
47
+ from ..profiles import Profile
48
+
49
+
50
+ def _ranges(kind: dict) -> list[tuple[int, int]]:
51
+ if "ranges" in kind:
52
+ return [tuple(r) for r in kind["ranges"]]
53
+ return [(kind["min"], kind["max"])]
54
+
55
+
56
+ def _in_range(v: float, kind: dict) -> bool:
57
+ return any(lo <= v <= hi for lo, hi in _ranges(kind))
58
+
59
+
60
+ def _cr6_burst_kind(kind: dict) -> bool:
61
+ return kind.get("transform") == "abs" and _ranges(kind) == [(201, 212)]
62
+
63
+
64
+ def _evaluate(value: float, kind: dict) -> tuple[bool, bool, int]:
65
+ """(in_range, odd_ok, displayed_value) for a constant `value` against one channel `kind`.
66
+
67
+ `transform: "abs"`: the real compiler's shown value and both tests use the argument's absolute
68
+ value -- burst-mode measurements encode "same channel, repeated" as a negative channel number
69
+ (`docs/loggers.md`, oracle: CR6 `VoltDiff` `DiffChan` -1 (raw -201) compiles, `TCSe` `SEChan`
70
+ -202 compiles). `transform: "none"` (every other kind): the raw value is both tested and shown.
71
+ """
72
+ v = abs(value) if kind.get("transform") == "abs" else value
73
+ if _cr6_burst_kind(kind) and -200 < value < 0:
74
+ v = value # a small negative is no burst code: tested and shown signed (oracle, CR6)
75
+ if kind.get("port_wrap"):
76
+ # CR6 ports (PortSet/PortGet/CS616, round 9): the value is cast to a 32-bit integer (out of
77
+ # range -> -2147483648, which is what the message shows) and any v >= 201 with v mod 200
78
+ # in 0..12 is accepted besides C1..C16/U1..U12 (400, 1000, 1001, 3012 compile; 200, 213,
79
+ # 413, 1100 do not).
80
+ v = int(v) if -(2**31) <= v < 2**31 else -(2**31)
81
+ in_range = _in_range(v, kind) or (v >= 201 and v % 200 <= 12)
82
+ return in_range, True, v
83
+ in_range = _in_range(v, kind)
84
+ odd_ok = (not kind.get("odd_only")) or (in_range and int(v) % 2 == 1)
85
+ return in_range, odd_ok, int(v)
86
+
87
+
88
+ CR6_BRIDGES = frozenset({"BRHALF", "BRHALF3W", "BRHALF4W", "BRFULL", "BRFULL6W"})
89
+
90
+
91
+ def check(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
92
+ terminals = profile.terminals
93
+ checked_instructions: dict = terminals.get("instructions", {})
94
+ channels: dict = terminals.get("channels", {})
95
+ if not checked_instructions:
96
+ return
97
+ from .instr_data import _with_inline
98
+
99
+ for stmt in _with_inline(program.statements): # a one-line If's statement too (round 58)
100
+ if not isinstance(stmt, S.Instruction):
101
+ continue
102
+ positions = checked_instructions.get(stmt.keyword.upper())
103
+ if not positions:
104
+ continue
105
+ if (
106
+ stmt.keyword.upper() == "ACPOWER"
107
+ and profile.name == "cr6"
108
+ and _check_acpower(stmt, channels[positions[0][1]], env, diags)
109
+ ):
110
+ continue
111
+ for pos, kind_name, *extra in positions:
112
+ # optional third and fourth items: the Reps position and the channels per repetition;
113
+ # the channel span is then checked (rounds 30-31, docs/checker.md#measurement-
114
+ # destination-and-channel-spans-round-31)
115
+ reps = _const_int(stmt, extra[0], env, truncate=True) if extra else None
116
+ width = extra[1] if len(extra) > 1 else 1
117
+ kind = channels[kind_name]
118
+ if reps is not None and profile.name == "cr6" and stmt.keyword.upper() in CR6_BRIDGES:
119
+ reps = min(reps, 1) # start channel only: `instr_measure._check_bridge_span` owns the span
120
+ if reps is not None and "span" in kind:
121
+ _check_arg(stmt, pos, kind, env, diags, reps=reps, width=width)
122
+ else: # CR300/CR350: the last channel used is tested and shown
123
+ _check_arg(stmt, pos, kind, env, diags, max(reps, 1) * width - 1 if reps is not None else 0)
124
+
125
+
126
+ def _const_int(stmt: S.Instruction, pos: int, env: consteval.Env, truncate: bool = False) -> int | None:
127
+ """An integral constant argument; `truncate` takes the integer part of any finite one."""
128
+ if pos > len(stmt.args) or stmt.args[pos - 1] is None or not consteval.is_constant(stmt.args[pos - 1], env):
129
+ return None
130
+ try:
131
+ value = consteval.value_of(stmt.args[pos - 1], env)
132
+ except consteval.NotConstant:
133
+ return None
134
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value):
135
+ return None
136
+ return int(value) if truncate or value == int(value) else None
137
+
138
+
139
+ def _check_acpower(stmt: S.Instruction, kind: dict, env: consteval.Env, diags: Diagnostics) -> bool:
140
+ """CR6 `ACPower` with a bad `ChanV` (4th) and/or `ChanI` (7th) and one or two `RepsI` (10th)
141
+ (oracle, 2026-10-10, `tests/fixtures/acpower_channels.json`): a bad voltage channel `v` prints
142
+ `v, v, -v, v`; per rep `k` (one or two) a bad voltage channel then adds `v` (from the second
143
+ rep) and a bad current channel `i` adds `-(i+k)`. True when handled."""
144
+ v, i, reps = (_const_int(stmt, pos, env) for pos in (4, 7, 10))
145
+ if v is None or i is None or reps not in (1, 2) or v < 0:
146
+ return False
147
+ v_bad, i_bad = not _in_range(v, kind), not _in_range(i, kind)
148
+ shown = [v, v, -v, v] if v_bad else []
149
+ for k in range(reps):
150
+ if k and v_bad:
151
+ shown.append(v)
152
+ if i_bad:
153
+ shown.append(-(i + k))
154
+ for value in shown:
155
+ diags.error(stmt.line, kind["message"].format(v=value), stmt.file)
156
+ return True
157
+
158
+
159
+ def _check_arg(
160
+ stmt: S.Instruction,
161
+ pos: int,
162
+ kind: dict,
163
+ env: consteval.Env,
164
+ diags: Diagnostics,
165
+ span: int = 0,
166
+ reps: int | None = None,
167
+ width: int = 1,
168
+ ) -> None:
169
+ idx = pos - 1
170
+ if idx >= len(stmt.args) or stmt.args[idx] is None:
171
+ return
172
+ arg = stmt.args[idx]
173
+ huge = False
174
+ if kind.get("huge_int_min") or "overflow_add" in kind:
175
+ # CR1000X (oracle, 2026-10-25, round 35): a constant outside int32 becomes INT_MIN; a
176
+ # single-ended channel then reports it, a differential one compiles, as does one whose
177
+ # `|channel| + overflow_add` passes INT_MAX (from 2147483641, any Reps)
178
+ try:
179
+ folded = consteval.value_of(arg, env) if consteval.is_constant(arg, env) else None
180
+ except consteval.NotConstant:
181
+ folded = None
182
+ if isinstance(folded, (int, float)) and not isinstance(folded, bool) and math.isfinite(folded):
183
+ huge = not -(2**31) < int(folded) < 2**31 # (INT_MIN itself too: `abs` keeps it)
184
+ if "overflow_add" in kind and (huge or abs(int(folded)) + kind["overflow_add"] > 2**31 - 1):
185
+ return
186
+ if (isinstance(arg, S.String) or huge) and kind.get("string_int_min" if not huge else "huge_int_min"):
187
+ # a string literal (`PortSet("fuzz",1)`, `"102"`, `""`) folds to INT_MIN (oracle, all
188
+ # four loggers, 2026-10-12)
189
+ value = -2147483648
190
+ in_range, odd_ok, _ = _evaluate(value, kind)
191
+ shown = value # (`abs` of INT_MIN is INT_MIN in C: CR1000X `VoltSe(X,1,mV1000,"abc",...)`)
192
+ if not in_range:
193
+ diags.error(stmt.line, kind["message"].format(v=shown), stmt.file)
194
+ if kind.get("secondary_when_invalid") and (not odd_ok or "allowed" in kind):
195
+ diags.error(
196
+ stmt.line,
197
+ (kind["odd_message"] if not odd_ok else kind["allowed_message"]).format(v=shown),
198
+ stmt.file,
199
+ )
200
+ return
201
+ if not consteval.is_constant(arg, env):
202
+ return # a variable here is already rejected by the generic const-expr parameter (type
203
+ # code 1's own "Variable illegal in Parameter" -- docs/type-codes.md); not our job.
204
+ try:
205
+ value = consteval.value_of(arg, env)
206
+ except consteval.NotConstant:
207
+ return
208
+ if not consteval.is_finite_number(value):
209
+ return
210
+ if kind.get("truncate") and math.isfinite(value):
211
+ value = int(value) # (CR1000X CurrentSE `402.5` is RG2, PulseCount `108.5` C8, round 31)
212
+ if kind.get("skip_nonpositive") and value <= 0:
213
+ # CR300/CR350 (docs/loggers.md): a channel <= 0 is accepted far outside any range this
214
+ # checker would derive with confidence (oracle: DiffChan -4 compiles with a 1..3 range,
215
+ # -100 fails as "Channel 99"; -1 also compiles for an SEChan that never takes negative
216
+ # values per its own CHM doc). Left unchecked rather than guessed, per AGENTS.md's "never
217
+ # falsely reject" priority -- every corpus mismatch this rule fixes is a plain positive
218
+ # channel number written where a different range or terminal name was required.
219
+ return
220
+ if _cr6_burst_kind(kind) and value <= -200 and not -212 <= value <= -201:
221
+ return # CR6 -200 and -213 and below behave irregularly (accepted, odd-start or port messages)
222
+ spec = kind.get("span") if reps is not None else None
223
+ top = max(hi for _, hi in _ranges(kind))
224
+ if spec is not None and not spec.get("start_upper", True) and value > top:
225
+ # CR6: a start channel above U12 is reported as the span (`250`: `Channel 50`, round 31)
226
+ if kind.get("odd_only") and int(value) % 2 != 1:
227
+ diags.error(stmt.line, kind["odd_message"].format(v=int(value)), stmt.file)
228
+ else:
229
+ diags.error(stmt.line, spec["message"].format(v=int(value) + spec.get("shift", 0)), stmt.file)
230
+ return
231
+ in_range, odd_ok, shown = _evaluate(value + span, kind)
232
+ allowed_ok = "allowed" not in kind or (in_range and shown in kind["allowed"])
233
+ if not in_range:
234
+ diags.error(stmt.line, kind["message"].format(v=shown), stmt.file)
235
+ if not kind.get("secondary_when_invalid"):
236
+ return
237
+ if not odd_ok:
238
+ diags.error(stmt.line, kind["odd_message"].format(v=shown), stmt.file)
239
+ elif not allowed_ok:
240
+ diags.error(stmt.line, kind["allowed_message"].format(v=shown), stmt.file)
241
+ elif spec is not None and reps is not None and reps > 1 and (value > 0 or not in_range):
242
+ # the last repetition's start channel must stay in the start's own range; the message
243
+ # shows the first channel past it (`spec`: own `ranges`, `step`, display `shift`). CR300/
244
+ # CR350 PulseCount spans an invalid start from `invalid_start` (round 31)
245
+ start = value if in_range else spec["invalid_start"]
246
+ last = start + (reps - 1) * width * spec.get("step", 1)
247
+ end = next(hi for lo, hi in _ranges(kind) if lo <= start <= hi)
248
+ if last > end or not _in_range(last, spec if "ranges" in spec else kind):
249
+ diags.error(stmt.line, spec["message"].format(v=int(end) + 1 + spec.get("shift", 0)), stmt.file)
@@ -0,0 +1,186 @@
1
+ """`-u`: `%s declared but not used.` (`docs/cli.md` "-u").
2
+
3
+ Oracle-confirmed this session (`tools/tdf_probe.py <snippet> --extra=-u`, real `CR6Comp.exe` under
4
+ Wine, cross-checked against `cr1000xcomp.exe`/`cr300comp.exe`/`cr350comp.exe` on the same snippet --
5
+ all four loggers print the identical messages in the identical format, so this module has no
6
+ per-logger branching):
7
+
8
+ - Flags a `Public`/`Dim` variable (the whole array/`String`, never a single element -- any
9
+ reference to *any* element marks the whole declaration used) and a `Sub`/`Function` parameter
10
+ that is never referenced anywhere else in the program text, each as `line N: Warning: %s declared
11
+ but not used.`.
12
+ - Also flags a `Sub` **or** `Function` that is never referenced anywhere else in the program, as a
13
+ *bare* `Warning: %s declared but not used.` -- no `line N:` prefix, the same "no line" shape as
14
+ the pre-existing "table declared but never called" warning (`checker/warnings.py`). An initial
15
+ reading of one oracle probe (an uncalled `Function` whose body sets its return value the usual
16
+ way, `MyFunc = value`) looked like an uncalled `Function` is *never* flagged, unlike an uncalled
17
+ `Sub` -- corrected once the real corpus (`ShuntLib.crb`'s `SampleShunt`, uncalled in
18
+ `Main.CR6`/`PowerBudget_tester.CR6`/`Preflight_tester.CR6` because its only call site sits behind
19
+ a false `#If`) turned out to *be* flagged: `MyFunc = value` inside the function's own body is
20
+ itself a reference to `MyFunc` (an ordinary `Assignment.target`, walked like any other), which is
21
+ what suppressed the warning in the first probe, not the `Function`/`Sub` distinction; a second
22
+ probe with `Return value` instead of self-assignment confirmed an uncalled `Function` *is*
23
+ flagged identically to an uncalled `Sub` once nothing else references its name.
24
+ - **Not** flagged: `Const`/`ConstTable` fields and an `Alias` name. Aliasing merges the alias name
25
+ and the underlying declared variable into one usage identity: a reference to the alias name
26
+ elsewhere in the program counts as using the original variable (oracle: `Public X` / `Alias X =
27
+ Y` / `Y = 1` -- `X` is *not* flagged), but the `Alias`/`Units`/`ReadOnly` statement's own mention
28
+ of a name is never itself a use (oracle: `Public X` / `Alias X = Y` alone, `Y` never referenced
29
+ again -- `X` *is* flagged; likewise a bare `Units X = Label` or `ReadOnly X` with no other
30
+ reference to `X` still flags it). A variable referenced only as a `DataTable` output source (a
31
+ `Sample`/`Average`/... argument) counts as used.
32
+ - The whole check is suppressed by *any* compile error anywhere in the file, not just the ones
33
+ after a given declaration (oracle: a single unrelated error anywhere removes every `-u` warning).
34
+ `compiler.compile_file` only calls `check` after the ordinary checker has run and found no
35
+ errors; this module does not re-check that itself.
36
+
37
+ Scope/limits: this project's syntax tree keeps block structure flat (`syntax.py`'s own docstring)
38
+ and does not model lexical scope -- like `checker/symbols.py`'s `declared_names`, a name here is
39
+ "used" if referenced *anywhere* in the program, including inside a different `Sub`/`Function` than
40
+ the one that declares it. Not independently oracle-tested against real per-scope shadowing (not
41
+ observed in this project's corpus). `Static` declarations were not investigated (rejected as
42
+ invalid syntax by the CR6 grammar in every oracle probe tried, and unused in `crbasic/`).
43
+ """
44
+
45
+ from .. import syntax as S
46
+ from ..diagnostics import Diagnostics
47
+
48
+ # Instructions that mention a variable's name as metadata, not as a real reference (oracle-
49
+ # confirmed for all three: `Alias X = Y`, `Units X = Label`, `ReadOnly X` never count as "using"
50
+ # `X` on their own).
51
+ _METADATA_ONLY = frozenset({"ALIAS", "UNITS", "READONLY"})
52
+
53
+
54
+ def _base_name(expr: S.Expr | None) -> str | None:
55
+ """The identifier a bare `Name` or single-element `Index` (`Arr(3)`) expression names, for
56
+ resolving an `Alias` statement's own operands -- `None` for anything else (a swath/complex
57
+ alias target, `docs/tdf-rules.md` §11 open question, is left unmerged rather than guessed at).
58
+ """
59
+ if isinstance(expr, S.Name):
60
+ return expr.name
61
+ if isinstance(expr, S.Index):
62
+ return expr.name
63
+ return None
64
+
65
+
66
+ def _walk_expr(expr: S.Expr | None, names: set[str]) -> None:
67
+ if expr is None:
68
+ return
69
+ if isinstance(expr, S.Name):
70
+ names.add(expr.name.upper())
71
+ elif isinstance(expr, S.Index):
72
+ names.add(expr.name.upper())
73
+ for a in expr.args:
74
+ _walk_expr(a, names)
75
+ elif isinstance(expr, S.Member):
76
+ _walk_expr(expr.base, names)
77
+ for a in expr.args or ():
78
+ _walk_expr(a, names)
79
+ elif isinstance(expr, S.Unary):
80
+ _walk_expr(expr.operand, names)
81
+ elif isinstance(expr, S.Binary):
82
+ _walk_expr(expr.left, names)
83
+ _walk_expr(expr.right, names)
84
+ elif isinstance(expr, S.ArrayLiteral):
85
+ for item in expr.items:
86
+ _walk_expr(item, names)
87
+ # Number/String: no name to collect.
88
+
89
+
90
+ def _walk_stmt(stmt: S.Stmt, names: set[str]) -> None:
91
+ """Every name a statement *references* (not declares) -- feeds the "used anywhere" set that
92
+ every declared Public/Dim/param/Sub name is checked against. `Declaration`'s own `VarDecl.name`
93
+ and `SubDef`'s own `name`/`Param.name` are never walked here (they are declarations, callers
94
+ collect those separately); a `VarDecl`'s `dims`/`init`/`string_size` and a `Param`'s `default`/
95
+ `string_size` genuinely can reference some *other* variable, so those are walked.
96
+ """
97
+ if isinstance(stmt, S.Declaration):
98
+ for v in stmt.vars:
99
+ for d in v.dims:
100
+ _walk_expr(d, names)
101
+ for e in v.init or ():
102
+ _walk_expr(e, names)
103
+ _walk_expr(v.string_size, names)
104
+ elif isinstance(stmt, S.ConstDecl):
105
+ _walk_expr(stmt.value, names)
106
+ elif isinstance(stmt, S.Assignment):
107
+ _walk_expr(stmt.target, names)
108
+ _walk_expr(stmt.value, names)
109
+ elif isinstance(stmt, S.Instruction):
110
+ if stmt.keyword.upper() in _METADATA_ONLY:
111
+ return
112
+ for a in stmt.args:
113
+ _walk_expr(a, names)
114
+ elif isinstance(stmt, S.If):
115
+ _walk_expr(stmt.cond, names)
116
+ for s2 in stmt.inline or ():
117
+ _walk_stmt(s2, names)
118
+ for s2 in stmt.inline_else or ():
119
+ _walk_stmt(s2, names)
120
+ elif isinstance(stmt, S.ElseIf):
121
+ _walk_expr(stmt.cond, names)
122
+ elif isinstance(stmt, S.For):
123
+ _walk_expr(stmt.var, names)
124
+ _walk_expr(stmt.start, names)
125
+ _walk_expr(stmt.end, names)
126
+ _walk_expr(stmt.step, names)
127
+ elif isinstance(stmt, S.DoLoop):
128
+ _walk_expr(stmt.cond, names)
129
+ elif isinstance(stmt, S.Case):
130
+ for item in stmt.items:
131
+ for part in item:
132
+ if isinstance(part, S.Expr):
133
+ _walk_expr(part, names)
134
+ elif isinstance(stmt, S.Call):
135
+ names.add(stmt.name.upper()) # `Call MySub(...)` / bare `MySub(...)`: a real call.
136
+ for a in stmt.args:
137
+ _walk_expr(a, names)
138
+ elif isinstance(stmt, S.SubDef):
139
+ for p in stmt.params:
140
+ _walk_expr(p.default, names)
141
+ _walk_expr(p.string_size, names)
142
+ # If/ElseIf/For/DoLoop/Case/Label/Unparsed themselves: nothing else to collect.
143
+
144
+
145
+ def check(program: S.Program, diags: Diagnostics) -> None:
146
+ """Add every `-u` warning for `program` to `diags`. Caller's responsibility (`compiler.py`):
147
+ only call this when `not diags.errors` (oracle-confirmed gate, see this module's docstring).
148
+ """
149
+ variables: list[tuple[str, int, str, str]] = [] # (NAME upper, line, display, file)
150
+ params: list[tuple[str, int, str, str]] = []
151
+ subs: list[tuple[str, str, str]] = [] # (NAME upper, display, file)
152
+ alias_map: dict[str, str] = {} # ALIAS-TARGET upper -> ORIGINAL-BASE upper
153
+
154
+ for stmt in program.statements:
155
+ if isinstance(stmt, S.Declaration) and stmt.kind.upper() in ("PUBLIC", "DIM"):
156
+ for v in stmt.vars:
157
+ variables.append((v.name.upper(), stmt.line, v.name, stmt.file))
158
+ elif isinstance(stmt, S.SubDef):
159
+ for p in stmt.params:
160
+ params.append((p.name.upper(), stmt.line, p.name, stmt.file))
161
+ subs.append((stmt.name.upper(), stmt.name, stmt.file))
162
+ elif isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "ALIAS" and stmt.args:
163
+ original = _base_name(stmt.args[0])
164
+ if original:
165
+ for target in stmt.args[1:]:
166
+ name = _base_name(target)
167
+ if name:
168
+ alias_map[name.upper()] = original.upper()
169
+
170
+ used: set[str] = set()
171
+ for stmt in program.statements:
172
+ _walk_stmt(stmt, used)
173
+ for name in list(used):
174
+ mapped = alias_map.get(name)
175
+ if mapped:
176
+ used.add(mapped)
177
+
178
+ for upper, line, display, file in variables:
179
+ if upper not in used:
180
+ diags.warning(line, f"{display} declared but not used.", file)
181
+ for upper, line, display, file in params:
182
+ if upper not in used:
183
+ diags.warning(line, f"{display} declared but not used.", file)
184
+ for upper, display, file in subs:
185
+ if upper not in used:
186
+ diags.warning(0, f"{display} declared but not used.", file)