qubasic 0.1.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.
- qbasic.py +113 -0
- qbasic_core/__init__.py +80 -0
- qbasic_core/__main__.py +6 -0
- qbasic_core/analysis.py +179 -0
- qbasic_core/backend.py +76 -0
- qbasic_core/classic.py +419 -0
- qbasic_core/control_flow.py +576 -0
- qbasic_core/debug.py +327 -0
- qbasic_core/demos.py +356 -0
- qbasic_core/display.py +274 -0
- qbasic_core/engine.py +126 -0
- qbasic_core/engine_state.py +109 -0
- qbasic_core/errors.py +37 -0
- qbasic_core/exec_context.py +24 -0
- qbasic_core/executor.py +861 -0
- qbasic_core/expression.py +228 -0
- qbasic_core/file_io.py +457 -0
- qbasic_core/gates.py +284 -0
- qbasic_core/help_text.py +167 -0
- qbasic_core/io_protocol.py +33 -0
- qbasic_core/locc.py +10 -0
- qbasic_core/locc_commands.py +221 -0
- qbasic_core/locc_display.py +61 -0
- qbasic_core/locc_engine.py +195 -0
- qbasic_core/locc_execution.py +389 -0
- qbasic_core/memory.py +369 -0
- qbasic_core/mock_backend.py +66 -0
- qbasic_core/noise_mixin.py +96 -0
- qbasic_core/parser.py +564 -0
- qbasic_core/patterns.py +186 -0
- qbasic_core/profiler.py +156 -0
- qbasic_core/program_mgmt.py +369 -0
- qbasic_core/protocol.py +77 -0
- qbasic_core/py.typed +0 -0
- qbasic_core/scope.py +74 -0
- qbasic_core/screen.py +115 -0
- qbasic_core/state_display.py +60 -0
- qbasic_core/statements.py +387 -0
- qbasic_core/strings.py +107 -0
- qbasic_core/subs.py +261 -0
- qbasic_core/sweep.py +82 -0
- qbasic_core/terminal.py +1697 -0
- qubasic-0.1.0.dist-info/METADATA +736 -0
- qubasic-0.1.0.dist-info/RECORD +48 -0
- qubasic-0.1.0.dist-info/WHEEL +5 -0
- qubasic-0.1.0.dist-info/entry_points.txt +2 -0
- qubasic-0.1.0.dist-info/licenses/LICENSE +21 -0
- qubasic-0.1.0.dist-info/top_level.txt +2 -0
qbasic_core/classic.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""QBASIC classic features — DATA/READ, ON GOTO, SELECT CASE, DO/LOOP, EXIT, SWAP, DEF FN, OPTION BASE."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from qbasic_core.engine import (
|
|
9
|
+
ExecResult, ExecOutcome,
|
|
10
|
+
RE_DATA, RE_READ, RE_ON_GOTO, RE_ON_GOSUB,
|
|
11
|
+
RE_SELECT_CASE, RE_CASE,
|
|
12
|
+
RE_DO, RE_LOOP_STMT, RE_EXIT,
|
|
13
|
+
RE_SWAP, RE_DEF_FN, RE_OPTION_BASE,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClassicMixin:
|
|
18
|
+
"""Classic BASIC features for QBasicTerminal.
|
|
19
|
+
|
|
20
|
+
Requires: TerminalProtocol — uses self.program, self.variables,
|
|
21
|
+
self.arrays, self._gosub_stack, self._eval_with_vars(),
|
|
22
|
+
self._eval_condition(), self.eval_expr().
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def _init_classic(self) -> None:
|
|
26
|
+
self._data_pool: list[Any] = []
|
|
27
|
+
self._data_ptr: int = 0
|
|
28
|
+
self._data_cache_key: tuple | None = None
|
|
29
|
+
self._option_base: int = 0
|
|
30
|
+
self._user_fns: dict[str, dict[str, Any]] = {}
|
|
31
|
+
self._select_stack: list[Any] = []
|
|
32
|
+
|
|
33
|
+
# Quantum state names recognized in DATA statements
|
|
34
|
+
_QUANTUM_STATES = {
|
|
35
|
+
'|0>': '0', '|1>': '1', '|+>': '+', '|->': '-',
|
|
36
|
+
'|0⟩': '0', '|1⟩': '1', '|+⟩': '+', '|-⟩': '-',
|
|
37
|
+
'|BELL>': 'BELL', '|GHZ>': 'GHZ', '|GHZ3>': 'GHZ3', '|GHZ4>': 'GHZ4',
|
|
38
|
+
'|W>': 'W', '|W3>': 'W3',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def _collect_data(self) -> None:
|
|
42
|
+
"""Scan program for DATA statements and build the data pool.
|
|
43
|
+
|
|
44
|
+
Recognizes quantum state names like |+>, |0>, |GHZ3> as special
|
|
45
|
+
string tokens that can trigger state preparation when READ assigns
|
|
46
|
+
them to a variable.
|
|
47
|
+
|
|
48
|
+
Caches the result keyed on DATA-containing lines so repeated RUN
|
|
49
|
+
calls skip the rescan when the program hasn't changed.
|
|
50
|
+
"""
|
|
51
|
+
# Build cache key from all DATA-containing lines
|
|
52
|
+
data_lines = []
|
|
53
|
+
for ln in sorted(self.program.keys()):
|
|
54
|
+
text = self.program[ln].strip()
|
|
55
|
+
if RE_DATA.match(text):
|
|
56
|
+
data_lines.append((ln, text))
|
|
57
|
+
cache_key = tuple(data_lines)
|
|
58
|
+
if cache_key == self._data_cache_key:
|
|
59
|
+
# Program DATA lines unchanged — just reset the read pointer
|
|
60
|
+
self._data_ptr = 0
|
|
61
|
+
return
|
|
62
|
+
self._data_pool = []
|
|
63
|
+
self._data_ptr = 0
|
|
64
|
+
for _ln, text in data_lines:
|
|
65
|
+
m = RE_DATA.match(text)
|
|
66
|
+
if m:
|
|
67
|
+
for item in m.group(1).split(','):
|
|
68
|
+
item = item.strip()
|
|
69
|
+
if (item.startswith('"') and item.endswith('"')) or \
|
|
70
|
+
(item.startswith("'") and item.endswith("'")):
|
|
71
|
+
self._data_pool.append(item[1:-1])
|
|
72
|
+
elif item in self._QUANTUM_STATES:
|
|
73
|
+
self._data_pool.append(f"QSTATE:{self._QUANTUM_STATES[item]}")
|
|
74
|
+
else:
|
|
75
|
+
try:
|
|
76
|
+
self._data_pool.append(float(item) if '.' in item else int(item))
|
|
77
|
+
except ValueError:
|
|
78
|
+
self._data_pool.append(item)
|
|
79
|
+
self._data_cache_key = cache_key
|
|
80
|
+
|
|
81
|
+
# ── DATA / READ / RESTORE ──────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def _cf_data(self, stmt: str, *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
84
|
+
"""DATA — skip during execution (collected before run)."""
|
|
85
|
+
if parsed is not None or RE_DATA.match(stmt):
|
|
86
|
+
return True, ExecResult.ADVANCE
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def _cf_read(self, stmt: str, run_vars: dict[str, Any], *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
90
|
+
if parsed is not None:
|
|
91
|
+
var_names = [v.strip() for v in parsed.var_list.split(',')]
|
|
92
|
+
else:
|
|
93
|
+
m = RE_READ.match(stmt)
|
|
94
|
+
if not m:
|
|
95
|
+
return None
|
|
96
|
+
var_names = [v.strip() for v in m.group(1).split(',')]
|
|
97
|
+
for vname in var_names:
|
|
98
|
+
if self._data_ptr >= len(self._data_pool):
|
|
99
|
+
raise RuntimeError("READ: OUT OF DATA")
|
|
100
|
+
val = self._data_pool[self._data_ptr]
|
|
101
|
+
self._data_ptr += 1
|
|
102
|
+
run_vars[vname] = val
|
|
103
|
+
self.variables[vname] = val
|
|
104
|
+
return True, ExecResult.ADVANCE
|
|
105
|
+
|
|
106
|
+
def cmd_restore(self, rest: str = '') -> None:
|
|
107
|
+
"""RESTORE — reset DATA pointer to beginning."""
|
|
108
|
+
self._data_ptr = 0
|
|
109
|
+
|
|
110
|
+
# ── ON expr GOTO / GOSUB ──────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def _cf_on_goto(self, stmt: str, run_vars: dict[str, Any],
|
|
113
|
+
sorted_lines: list[int], *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
114
|
+
if parsed is not None:
|
|
115
|
+
idx = int(self._eval_with_vars(parsed.expr, run_vars))
|
|
116
|
+
targets = list(parsed.targets)
|
|
117
|
+
else:
|
|
118
|
+
m = RE_ON_GOTO.match(stmt)
|
|
119
|
+
if not m:
|
|
120
|
+
return None
|
|
121
|
+
idx = int(self._eval_with_vars(m.group(1).strip(), run_vars))
|
|
122
|
+
targets = [int(t.strip()) for t in m.group(2).split(',') if t.strip()]
|
|
123
|
+
if 1 <= idx <= len(targets):
|
|
124
|
+
target = targets[idx - 1]
|
|
125
|
+
for i, ln in enumerate(sorted_lines):
|
|
126
|
+
if ln == target:
|
|
127
|
+
return True, i
|
|
128
|
+
raise RuntimeError(f"ON GOTO: LINE {target} NOT FOUND")
|
|
129
|
+
return True, ExecResult.ADVANCE # out of range: fall through
|
|
130
|
+
|
|
131
|
+
def _cf_on_gosub(self, stmt: str, run_vars: dict[str, Any],
|
|
132
|
+
sorted_lines: list[int], ip: int, *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
133
|
+
if parsed is not None:
|
|
134
|
+
idx = int(self._eval_with_vars(parsed.expr, run_vars))
|
|
135
|
+
targets = list(parsed.targets)
|
|
136
|
+
else:
|
|
137
|
+
m = RE_ON_GOSUB.match(stmt)
|
|
138
|
+
if not m:
|
|
139
|
+
return None
|
|
140
|
+
idx = int(self._eval_with_vars(m.group(1).strip(), run_vars))
|
|
141
|
+
targets = [int(t.strip()) for t in m.group(2).split(',') if t.strip()]
|
|
142
|
+
if 1 <= idx <= len(targets):
|
|
143
|
+
target = targets[idx - 1]
|
|
144
|
+
self._gosub_stack.append(ip + 1)
|
|
145
|
+
for i, ln in enumerate(sorted_lines):
|
|
146
|
+
if ln == target:
|
|
147
|
+
return True, i
|
|
148
|
+
raise RuntimeError(f"ON GOSUB: LINE {target} NOT FOUND")
|
|
149
|
+
return True, ExecResult.ADVANCE
|
|
150
|
+
|
|
151
|
+
# ── SELECT CASE ───────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def _cf_select_case(self, stmt: str, run_vars: dict[str, Any],
|
|
154
|
+
sorted_lines: list[int], ip: int, *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
155
|
+
if parsed is not None:
|
|
156
|
+
test_val = self._eval_with_vars(parsed.expr, run_vars)
|
|
157
|
+
else:
|
|
158
|
+
m = RE_SELECT_CASE.match(stmt)
|
|
159
|
+
if not m:
|
|
160
|
+
return None
|
|
161
|
+
test_val = self._eval_with_vars(m.group(1).strip(), run_vars)
|
|
162
|
+
self._select_stack.append(test_val)
|
|
163
|
+
# Scan forward for matching CASE
|
|
164
|
+
scan = ip + 1
|
|
165
|
+
depth = 1
|
|
166
|
+
while scan < len(sorted_lines):
|
|
167
|
+
s = self.program[sorted_lines[scan]].strip().upper()
|
|
168
|
+
if s.startswith('SELECT CASE'):
|
|
169
|
+
depth += 1
|
|
170
|
+
elif s == 'END SELECT':
|
|
171
|
+
depth -= 1
|
|
172
|
+
if depth == 0:
|
|
173
|
+
self._select_stack.pop()
|
|
174
|
+
return True, scan + 1 # no matching case
|
|
175
|
+
elif depth == 1 and s.startswith('CASE '):
|
|
176
|
+
case_val = s[5:].strip()
|
|
177
|
+
if case_val == 'ELSE':
|
|
178
|
+
return True, scan + 1 # execute CASE ELSE block
|
|
179
|
+
try:
|
|
180
|
+
if float(test_val) == float(self._eval_with_vars(case_val, run_vars)):
|
|
181
|
+
return True, scan + 1 # execute this CASE block
|
|
182
|
+
except (ValueError, TypeError):
|
|
183
|
+
if str(test_val) == case_val:
|
|
184
|
+
return True, scan + 1
|
|
185
|
+
scan += 1
|
|
186
|
+
raise RuntimeError("SELECT CASE without END SELECT")
|
|
187
|
+
|
|
188
|
+
def _cf_case(self, stmt: str, sorted_lines: list[int], ip: int, *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
189
|
+
if parsed is None:
|
|
190
|
+
m = RE_CASE.match(stmt)
|
|
191
|
+
if not m:
|
|
192
|
+
return None
|
|
193
|
+
# If we reach a CASE during execution, skip to END SELECT (we already matched)
|
|
194
|
+
depth = 1
|
|
195
|
+
scan = ip + 1
|
|
196
|
+
while scan < len(sorted_lines):
|
|
197
|
+
s = self.program[sorted_lines[scan]].strip().upper()
|
|
198
|
+
if s.startswith('SELECT CASE'):
|
|
199
|
+
depth += 1
|
|
200
|
+
elif s == 'END SELECT':
|
|
201
|
+
depth -= 1
|
|
202
|
+
if depth == 0:
|
|
203
|
+
if self._select_stack:
|
|
204
|
+
self._select_stack.pop()
|
|
205
|
+
return True, scan + 1
|
|
206
|
+
scan += 1
|
|
207
|
+
return True, ExecResult.ADVANCE
|
|
208
|
+
|
|
209
|
+
def _cf_end_select(self, stmt: str, *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
210
|
+
if parsed is not None or stmt.strip().upper() == 'END SELECT':
|
|
211
|
+
if not self._select_stack:
|
|
212
|
+
raise RuntimeError("END SELECT without matching SELECT CASE")
|
|
213
|
+
self._select_stack.pop()
|
|
214
|
+
return True, ExecResult.ADVANCE
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
# ── DO / LOOP ─────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def _find_matching_loop(self, sorted_lines: list[int], ip: int) -> int:
|
|
220
|
+
depth = 1
|
|
221
|
+
scan = ip + 1
|
|
222
|
+
while scan < len(sorted_lines):
|
|
223
|
+
s = self.program[sorted_lines[scan]].strip().upper()
|
|
224
|
+
if s.startswith('DO'):
|
|
225
|
+
depth += 1
|
|
226
|
+
elif s.startswith('LOOP'):
|
|
227
|
+
depth -= 1
|
|
228
|
+
if depth == 0:
|
|
229
|
+
return scan
|
|
230
|
+
scan += 1
|
|
231
|
+
raise RuntimeError(f"DO at line {sorted_lines[ip]} has no matching LOOP")
|
|
232
|
+
|
|
233
|
+
def _cf_do(self, stmt: str, run_vars: dict[str, Any],
|
|
234
|
+
loop_stack: list[dict[str, Any]],
|
|
235
|
+
sorted_lines: list[int], ip: int, *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
236
|
+
if parsed is not None:
|
|
237
|
+
kind = parsed.kind
|
|
238
|
+
cond = parsed.condition
|
|
239
|
+
else:
|
|
240
|
+
m = RE_DO.match(stmt)
|
|
241
|
+
if not m:
|
|
242
|
+
return None
|
|
243
|
+
kind = m.group(1) # WHILE or UNTIL or None
|
|
244
|
+
cond = m.group(2) # condition or None
|
|
245
|
+
if kind and cond:
|
|
246
|
+
# Pre-test
|
|
247
|
+
result = self._eval_condition(cond, run_vars)
|
|
248
|
+
if kind.upper() == 'UNTIL':
|
|
249
|
+
result = not result
|
|
250
|
+
if not result:
|
|
251
|
+
# Skip to after LOOP
|
|
252
|
+
loop_ip = self._find_matching_loop(sorted_lines, ip)
|
|
253
|
+
return True, loop_ip + 1
|
|
254
|
+
loop_stack.append({'type': 'do', 'return_ip': ip, 'kind': kind, 'cond': cond})
|
|
255
|
+
return True, ExecResult.ADVANCE
|
|
256
|
+
|
|
257
|
+
def _cf_loop(self, stmt: str, run_vars: dict[str, Any],
|
|
258
|
+
loop_stack: list[dict[str, Any]],
|
|
259
|
+
sorted_lines: list[int], ip: int, *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
260
|
+
if parsed is not None:
|
|
261
|
+
loop_kind = parsed.kind
|
|
262
|
+
loop_cond = parsed.condition
|
|
263
|
+
else:
|
|
264
|
+
m = RE_LOOP_STMT.match(stmt)
|
|
265
|
+
if not m:
|
|
266
|
+
return None
|
|
267
|
+
loop_kind = m.group(1)
|
|
268
|
+
loop_cond = m.group(2)
|
|
269
|
+
# Only handle as DO/LOOP keyword if we're inside a DO block
|
|
270
|
+
if not loop_stack or loop_stack[-1].get('type') != 'do':
|
|
271
|
+
# Bare "LOOP" (no WHILE/UNTIL) that matches a SUB name should
|
|
272
|
+
# be handed off to subroutine expansion, not treated as a keyword.
|
|
273
|
+
is_bare = loop_kind is None # no WHILE/UNTIL suffix
|
|
274
|
+
if is_bare and hasattr(self, '_sub_defs') and 'LOOP' in self._sub_defs:
|
|
275
|
+
return None # let subroutine expansion handle it
|
|
276
|
+
# Otherwise it's either bare LOOP with no sub, or LOOP WHILE/UNTIL
|
|
277
|
+
# without a DO — both are errors or no-ops. Return None so the
|
|
278
|
+
# caller can decide (gate dispatch will surface an error if needed).
|
|
279
|
+
return None
|
|
280
|
+
loop = loop_stack[-1]
|
|
281
|
+
# Post-test condition on LOOP
|
|
282
|
+
kind = loop_kind
|
|
283
|
+
cond = loop_cond
|
|
284
|
+
if kind and cond:
|
|
285
|
+
result = self._eval_condition(cond, run_vars)
|
|
286
|
+
if kind.upper() == 'UNTIL':
|
|
287
|
+
result = not result
|
|
288
|
+
if result:
|
|
289
|
+
return True, loop['return_ip']
|
|
290
|
+
else:
|
|
291
|
+
loop_stack.pop()
|
|
292
|
+
return True, ExecResult.ADVANCE
|
|
293
|
+
# Pre-test condition was on DO
|
|
294
|
+
if loop.get('kind') and loop.get('cond'):
|
|
295
|
+
result = self._eval_condition(loop['cond'], run_vars)
|
|
296
|
+
if loop['kind'].upper() == 'UNTIL':
|
|
297
|
+
result = not result
|
|
298
|
+
if result:
|
|
299
|
+
return True, loop['return_ip']
|
|
300
|
+
else:
|
|
301
|
+
loop_stack.pop()
|
|
302
|
+
return True, ExecResult.ADVANCE
|
|
303
|
+
# Infinite DO/LOOP (no condition) — loop forever
|
|
304
|
+
return True, loop['return_ip']
|
|
305
|
+
|
|
306
|
+
# ── EXIT ──────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
def _cf_exit(self, stmt: str, loop_stack: list[dict[str, Any]],
|
|
309
|
+
sorted_lines: list[int], ip: int, *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
310
|
+
if parsed is not None:
|
|
311
|
+
kind = parsed.target
|
|
312
|
+
else:
|
|
313
|
+
m = RE_EXIT.match(stmt)
|
|
314
|
+
if not m:
|
|
315
|
+
return None
|
|
316
|
+
kind = m.group(1).upper()
|
|
317
|
+
if kind == 'FOR':
|
|
318
|
+
# Find matching NEXT
|
|
319
|
+
scan = ip + 1
|
|
320
|
+
depth = 1
|
|
321
|
+
while scan < len(sorted_lines):
|
|
322
|
+
s = self.program[sorted_lines[scan]].strip().upper()
|
|
323
|
+
if s.startswith('FOR '):
|
|
324
|
+
depth += 1
|
|
325
|
+
elif s.startswith('NEXT '):
|
|
326
|
+
depth -= 1
|
|
327
|
+
if depth == 0:
|
|
328
|
+
if loop_stack and loop_stack[-1].get('var'):
|
|
329
|
+
loop_stack.pop()
|
|
330
|
+
return True, scan + 1
|
|
331
|
+
scan += 1
|
|
332
|
+
elif kind == 'WHILE':
|
|
333
|
+
scan = ip + 1
|
|
334
|
+
depth = 1
|
|
335
|
+
while scan < len(sorted_lines):
|
|
336
|
+
s = self.program[sorted_lines[scan]].strip().upper()
|
|
337
|
+
if s.startswith('WHILE '):
|
|
338
|
+
depth += 1
|
|
339
|
+
elif s == 'WEND':
|
|
340
|
+
depth -= 1
|
|
341
|
+
if depth == 0:
|
|
342
|
+
if loop_stack and loop_stack[-1].get('type') == 'while':
|
|
343
|
+
loop_stack.pop()
|
|
344
|
+
return True, scan + 1
|
|
345
|
+
scan += 1
|
|
346
|
+
elif kind == 'DO':
|
|
347
|
+
scan = ip + 1
|
|
348
|
+
depth = 1
|
|
349
|
+
while scan < len(sorted_lines):
|
|
350
|
+
s = self.program[sorted_lines[scan]].strip().upper()
|
|
351
|
+
if s.startswith('DO'):
|
|
352
|
+
depth += 1
|
|
353
|
+
elif s.startswith('LOOP'):
|
|
354
|
+
depth -= 1
|
|
355
|
+
if depth == 0:
|
|
356
|
+
if loop_stack and loop_stack[-1].get('type') == 'do':
|
|
357
|
+
loop_stack.pop()
|
|
358
|
+
return True, scan + 1
|
|
359
|
+
scan += 1
|
|
360
|
+
elif kind in ('SUB', 'FUNCTION'):
|
|
361
|
+
return True, ExecResult.END
|
|
362
|
+
raise RuntimeError(f"EXIT {kind}: no matching block")
|
|
363
|
+
|
|
364
|
+
# ── SWAP ──────────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
def _cf_swap(self, stmt: str, run_vars: dict[str, Any], *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
367
|
+
if parsed is not None:
|
|
368
|
+
a, b = parsed.a, parsed.b
|
|
369
|
+
else:
|
|
370
|
+
m = RE_SWAP.match(stmt)
|
|
371
|
+
if not m:
|
|
372
|
+
return None
|
|
373
|
+
a, b = m.group(1), m.group(2)
|
|
374
|
+
va = run_vars.get(a, self.variables.get(a, 0))
|
|
375
|
+
vb = run_vars.get(b, self.variables.get(b, 0))
|
|
376
|
+
run_vars[a] = vb
|
|
377
|
+
run_vars[b] = va
|
|
378
|
+
self.variables[a] = vb
|
|
379
|
+
self.variables[b] = va
|
|
380
|
+
return True, ExecResult.ADVANCE
|
|
381
|
+
|
|
382
|
+
# ── DEF FN ────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
def _cf_def_fn(self, stmt: str, run_vars: dict[str, Any], *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
385
|
+
if parsed is not None:
|
|
386
|
+
name = 'FN' + parsed.name.upper()
|
|
387
|
+
params = [p.strip() for p in parsed.params.split(',') if p.strip()]
|
|
388
|
+
body = parsed.body.strip()
|
|
389
|
+
else:
|
|
390
|
+
m = RE_DEF_FN.match(stmt)
|
|
391
|
+
if not m:
|
|
392
|
+
return None
|
|
393
|
+
name = 'FN' + m.group(1).upper()
|
|
394
|
+
params = [p.strip() for p in m.group(2).split(',') if p.strip()]
|
|
395
|
+
body = m.group(3).strip()
|
|
396
|
+
self._user_fns[name] = {'params': params, 'body': body}
|
|
397
|
+
return True, ExecResult.ADVANCE
|
|
398
|
+
|
|
399
|
+
def _call_user_fn(self, name: str, args: list[float]) -> float:
|
|
400
|
+
"""Call a DEF FN function."""
|
|
401
|
+
fn = self._user_fns.get(name.upper())
|
|
402
|
+
if fn is None:
|
|
403
|
+
raise ValueError(f"UNDEFINED FUNCTION: {name}")
|
|
404
|
+
ns = {}
|
|
405
|
+
for i, pname in enumerate(fn['params']):
|
|
406
|
+
ns[pname] = args[i] if i < len(args) else 0
|
|
407
|
+
return float(self._safe_eval(fn['body'], extra_ns=ns))
|
|
408
|
+
|
|
409
|
+
# ── OPTION BASE ───────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
def _cf_option_base(self, stmt: str, *, parsed=None) -> tuple[bool, ExecOutcome] | None:
|
|
412
|
+
if parsed is not None:
|
|
413
|
+
self._option_base = parsed.base
|
|
414
|
+
else:
|
|
415
|
+
m = RE_OPTION_BASE.match(stmt)
|
|
416
|
+
if not m:
|
|
417
|
+
return None
|
|
418
|
+
self._option_base = int(m.group(1))
|
|
419
|
+
return True, ExecResult.ADVANCE
|