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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""QBASIC expression evaluation — safe AST-based evaluator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import math
|
|
7
|
+
import ast
|
|
8
|
+
import time
|
|
9
|
+
import random
|
|
10
|
+
import operator
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExpressionMixin:
|
|
15
|
+
"""AST-based safe expression evaluation. No eval().
|
|
16
|
+
|
|
17
|
+
Requires: TerminalProtocol — uses self.variables, self.arrays.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_SAFE_FUNCS = {
|
|
21
|
+
'sin': math.sin, 'cos': math.cos, 'tan': math.tan,
|
|
22
|
+
'asin': math.asin, 'acos': math.acos, 'atan': math.atan,
|
|
23
|
+
'atan2': math.atan2,
|
|
24
|
+
'sqrt': math.sqrt, 'log': math.log, 'exp': math.exp,
|
|
25
|
+
'abs': abs, 'int': int, 'float': float,
|
|
26
|
+
'min': min, 'max': max, 'round': round, 'len': len,
|
|
27
|
+
'ceil': math.ceil, 'floor': math.floor,
|
|
28
|
+
}
|
|
29
|
+
_SAFE_CONSTS = {
|
|
30
|
+
'PI': math.pi, 'pi': math.pi,
|
|
31
|
+
'TAU': math.tau, 'tau': math.tau,
|
|
32
|
+
'E': math.e, 'e': math.e,
|
|
33
|
+
'SQRT2': math.sqrt(2), 'sqrt2': math.sqrt(2),
|
|
34
|
+
'True': True, 'False': False,
|
|
35
|
+
}
|
|
36
|
+
_AST_OPS = {
|
|
37
|
+
ast.Add: operator.add, ast.Sub: operator.sub,
|
|
38
|
+
ast.Mult: operator.mul, ast.Div: operator.truediv,
|
|
39
|
+
ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
|
|
40
|
+
ast.Pow: operator.pow,
|
|
41
|
+
ast.USub: operator.neg, ast.UAdd: operator.pos,
|
|
42
|
+
ast.Not: operator.not_,
|
|
43
|
+
ast.Eq: operator.eq, ast.NotEq: operator.ne,
|
|
44
|
+
ast.Lt: operator.lt, ast.LtE: operator.le,
|
|
45
|
+
ast.Gt: operator.gt, ast.GtE: operator.ge,
|
|
46
|
+
ast.And: lambda a, b: a and b,
|
|
47
|
+
ast.Or: lambda a, b: a or b,
|
|
48
|
+
ast.BitAnd: operator.and_,
|
|
49
|
+
ast.BitOr: operator.or_,
|
|
50
|
+
ast.BitXor: operator.xor,
|
|
51
|
+
ast.Invert: operator.invert,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def _ast_eval(self, node: ast.AST, ns: dict[str, Any]) -> Any:
|
|
55
|
+
"""Recursively evaluate an AST node against a safe namespace."""
|
|
56
|
+
if isinstance(node, ast.Expression):
|
|
57
|
+
return self._ast_eval(node.body, ns)
|
|
58
|
+
if isinstance(node, ast.Constant):
|
|
59
|
+
if isinstance(node.value, (int, float, complex, str)):
|
|
60
|
+
return node.value
|
|
61
|
+
raise ValueError(f"UNSUPPORTED CONSTANT: {node.value!r}")
|
|
62
|
+
if isinstance(node, ast.Name):
|
|
63
|
+
if node.id in ns:
|
|
64
|
+
return ns[node.id]
|
|
65
|
+
raise ValueError(f"UNDEFINED: {node.id}")
|
|
66
|
+
if isinstance(node, ast.UnaryOp):
|
|
67
|
+
op = self._AST_OPS.get(type(node.op))
|
|
68
|
+
if op is None:
|
|
69
|
+
raise ValueError(f"UNSUPPORTED OP: {type(node.op).__name__}")
|
|
70
|
+
return op(self._ast_eval(node.operand, ns))
|
|
71
|
+
if isinstance(node, ast.BinOp):
|
|
72
|
+
op = self._AST_OPS.get(type(node.op))
|
|
73
|
+
if op is None:
|
|
74
|
+
raise ValueError(f"UNSUPPORTED OP: {type(node.op).__name__}")
|
|
75
|
+
return op(self._ast_eval(node.left, ns), self._ast_eval(node.right, ns))
|
|
76
|
+
if isinstance(node, ast.BoolOp):
|
|
77
|
+
op = self._AST_OPS.get(type(node.op))
|
|
78
|
+
if op is None:
|
|
79
|
+
raise ValueError(f"UNSUPPORTED OP: {type(node.op).__name__}")
|
|
80
|
+
result = self._ast_eval(node.values[0], ns)
|
|
81
|
+
for val in node.values[1:]:
|
|
82
|
+
result = op(result, self._ast_eval(val, ns))
|
|
83
|
+
return result
|
|
84
|
+
if isinstance(node, ast.Compare):
|
|
85
|
+
left = self._ast_eval(node.left, ns)
|
|
86
|
+
for op_node, comparator in zip(node.ops, node.comparators):
|
|
87
|
+
op = self._AST_OPS.get(type(op_node))
|
|
88
|
+
if op is None:
|
|
89
|
+
raise ValueError(f"UNSUPPORTED OP: {type(op_node).__name__}")
|
|
90
|
+
right = self._ast_eval(comparator, ns)
|
|
91
|
+
if not op(left, right):
|
|
92
|
+
return False
|
|
93
|
+
left = right
|
|
94
|
+
return True
|
|
95
|
+
if isinstance(node, ast.Call):
|
|
96
|
+
if not isinstance(node.func, ast.Name):
|
|
97
|
+
raise ValueError("ONLY SIMPLE FUNCTION CALLS ALLOWED")
|
|
98
|
+
fname = node.func.id
|
|
99
|
+
func = ns.get(fname)
|
|
100
|
+
if not callable(func):
|
|
101
|
+
raise ValueError(f"NOT A FUNCTION: {fname}")
|
|
102
|
+
args = [self._ast_eval(a, ns) for a in node.args]
|
|
103
|
+
return func(*args)
|
|
104
|
+
if isinstance(node, ast.IfExp):
|
|
105
|
+
if self._ast_eval(node.test, ns):
|
|
106
|
+
return self._ast_eval(node.body, ns)
|
|
107
|
+
return self._ast_eval(node.orelse, ns)
|
|
108
|
+
if isinstance(node, ast.Subscript):
|
|
109
|
+
if isinstance(node.value, ast.Name):
|
|
110
|
+
name = node.value.id
|
|
111
|
+
idx = int(self._ast_eval(node.slice, ns))
|
|
112
|
+
if name in self.arrays:
|
|
113
|
+
return self.arrays[name][idx]
|
|
114
|
+
raise ValueError("UNSUPPORTED SUBSCRIPT")
|
|
115
|
+
raise ValueError(f"UNSUPPORTED EXPRESSION: {ast.dump(node)}")
|
|
116
|
+
|
|
117
|
+
def _safe_eval(self, expr: Any, extra_ns: dict[str, Any] | None = None) -> Any:
|
|
118
|
+
"""Evaluate expression using AST walking — no eval()."""
|
|
119
|
+
ns = {**self._SAFE_CONSTS, **self._SAFE_FUNCS, **self.variables}
|
|
120
|
+
if extra_ns:
|
|
121
|
+
ns.update(extra_ns)
|
|
122
|
+
for aname, adata in self.arrays.items():
|
|
123
|
+
ns[aname] = lambda i, d=adata: d[int(i)]
|
|
124
|
+
# Instance-specific functions
|
|
125
|
+
ns['RND'] = lambda x=0: random.random()
|
|
126
|
+
ns['TIMER'] = getattr(self, '_run_timer', time.time)()
|
|
127
|
+
ns['POS'] = lambda x=0: 0
|
|
128
|
+
if hasattr(self, '_peek'):
|
|
129
|
+
ns['PEEK'] = lambda addr: self._peek(addr)
|
|
130
|
+
if hasattr(self, '_usr_fn'):
|
|
131
|
+
ns['USR'] = lambda addr: self._usr_fn(addr)
|
|
132
|
+
if hasattr(self, '_eof'):
|
|
133
|
+
ns['EOF'] = lambda h: self._eof(h)
|
|
134
|
+
if hasattr(self, '_get_string_ns'):
|
|
135
|
+
ns.update(self._get_string_ns())
|
|
136
|
+
if hasattr(self, '_user_fns'):
|
|
137
|
+
for fname, fdef in self._user_fns.items():
|
|
138
|
+
fn_params = fdef['params']
|
|
139
|
+
fn_body = fdef['body']
|
|
140
|
+
ns[fname] = lambda *args, p=fn_params, b=fn_body: self._call_user_fn_expr(p, b, args)
|
|
141
|
+
# Also register without FN prefix and in lowercase for flexible invocation
|
|
142
|
+
for fname, fdef in self._user_fns.items():
|
|
143
|
+
fn_params = fdef['params']
|
|
144
|
+
fn_body = fdef['body']
|
|
145
|
+
fn = lambda *args, p=fn_params, b=fn_body: self._call_user_fn_expr(p, b, args)
|
|
146
|
+
# Strip FN prefix if present, add as both upper and lower
|
|
147
|
+
short = fname[2:] if fname.upper().startswith('FN') else fname
|
|
148
|
+
ns[short] = fn
|
|
149
|
+
ns[short.lower()] = fn
|
|
150
|
+
ns[short.upper()] = fn
|
|
151
|
+
try:
|
|
152
|
+
import psutil
|
|
153
|
+
ns['FRE'] = lambda x=0: psutil.virtual_memory().available
|
|
154
|
+
except ImportError:
|
|
155
|
+
ns['FRE'] = lambda x=0: 0
|
|
156
|
+
# Add Python-safe aliases for all $-suffixed keys in namespace
|
|
157
|
+
for k, v in list(ns.items()):
|
|
158
|
+
if '$' in k:
|
|
159
|
+
ns[k.replace('$', '_S_')] = v
|
|
160
|
+
# Hex/bin prefix support
|
|
161
|
+
expr_str = str(expr).strip()
|
|
162
|
+
expr_str = re.sub(r'&H([0-9A-Fa-f]+)', r'0x\1', expr_str)
|
|
163
|
+
expr_str = re.sub(r'&B([01]+)', r'0b\1', expr_str)
|
|
164
|
+
# Normalize FN prefix: "FN square(x)" -> "square(x)"
|
|
165
|
+
expr_str = re.sub(r'\bFN\s+(\w+)', r'\1', expr_str, flags=re.IGNORECASE)
|
|
166
|
+
# Normalize $ in identifiers for Python AST compatibility
|
|
167
|
+
expr_str = re.sub(r'(\w+)\$', r'\1_S_', expr_str)
|
|
168
|
+
if not expr_str:
|
|
169
|
+
raise ValueError("EMPTY EXPRESSION")
|
|
170
|
+
try:
|
|
171
|
+
tree = ast.parse(expr_str, mode='eval')
|
|
172
|
+
except SyntaxError as e:
|
|
173
|
+
raise ValueError(f"SYNTAX ERROR: {e}") from None
|
|
174
|
+
return self._ast_eval(tree, ns)
|
|
175
|
+
|
|
176
|
+
def _parse_matrix(self, text: str) -> list[Any]:
|
|
177
|
+
"""Parse a matrix literal like [[1,0],[0,1]] using AST — no eval()."""
|
|
178
|
+
ns = {**self._SAFE_CONSTS, **self._SAFE_FUNCS, 'j': 1j, 'im': 1j}
|
|
179
|
+
try:
|
|
180
|
+
tree = ast.parse(text.strip(), mode='eval')
|
|
181
|
+
except SyntaxError as e:
|
|
182
|
+
raise ValueError(f"MATRIX SYNTAX ERROR: {e}") from None
|
|
183
|
+
return self._ast_eval_matrix(tree.body, ns)
|
|
184
|
+
|
|
185
|
+
def _ast_eval_matrix(self, node: ast.AST, ns: dict[str, Any]) -> Any:
|
|
186
|
+
"""Evaluate an AST node that should resolve to a nested list of numbers."""
|
|
187
|
+
if isinstance(node, ast.List):
|
|
188
|
+
return [self._ast_eval_matrix(elt, ns) for elt in node.elts]
|
|
189
|
+
return complex(self._ast_eval(node, ns))
|
|
190
|
+
|
|
191
|
+
def eval_expr(self, expr: str) -> float:
|
|
192
|
+
"""Evaluate a mathematical expression with variables."""
|
|
193
|
+
try:
|
|
194
|
+
return float(self._safe_eval(expr))
|
|
195
|
+
except ValueError:
|
|
196
|
+
raise
|
|
197
|
+
except Exception:
|
|
198
|
+
raise ValueError(f"CANNOT EVALUATE: {expr}")
|
|
199
|
+
|
|
200
|
+
def _eval_with_vars(self, expr: str, run_vars: dict[str, Any]) -> float:
|
|
201
|
+
"""Evaluate expression with runtime variables."""
|
|
202
|
+
return float(self._safe_eval(expr, extra_ns=run_vars))
|
|
203
|
+
|
|
204
|
+
def _eval_condition(self, cond: str, run_vars: dict[str, Any]) -> bool:
|
|
205
|
+
"""Evaluate a boolean condition."""
|
|
206
|
+
cond = cond.replace('<>', '!=').replace('><', '!=')
|
|
207
|
+
cond = re.sub(r'\bAND\b', ' and ', cond, flags=re.IGNORECASE)
|
|
208
|
+
cond = re.sub(r'\bOR\b', ' or ', cond, flags=re.IGNORECASE)
|
|
209
|
+
cond = re.sub(r'\bNOT\b', ' not ', cond, flags=re.IGNORECASE)
|
|
210
|
+
cond = re.sub(r'\bXOR\b', ' ^ ', cond, flags=re.IGNORECASE)
|
|
211
|
+
return bool(self._safe_eval(cond, extra_ns=run_vars))
|
|
212
|
+
|
|
213
|
+
def _run_timer(self) -> float:
|
|
214
|
+
"""Return elapsed time since terminal start."""
|
|
215
|
+
return time.time() - getattr(self, '_start_time', time.time())
|
|
216
|
+
|
|
217
|
+
def _call_user_fn_expr(self, params: list[str], body: str, args: tuple) -> float:
|
|
218
|
+
"""Call a DEF FN function from within expression evaluation."""
|
|
219
|
+
if len(args) < len(params):
|
|
220
|
+
missing = params[len(args):]
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"DEF FN requires {len(params)} argument(s), got {len(args)}; "
|
|
223
|
+
f"missing: {', '.join(missing)}"
|
|
224
|
+
)
|
|
225
|
+
ns: dict[str, Any] = {}
|
|
226
|
+
for i, pname in enumerate(params):
|
|
227
|
+
ns[pname] = args[i]
|
|
228
|
+
return float(self._safe_eval(body, extra_ns=ns))
|
qbasic_core/file_io.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""QBASIC file I/O mixin — SAVE, LOAD, INCLUDE, DIR, EXPORT, CSV, OPEN/CLOSE/PRINT#/INPUT#/EOF/LPRINT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from qbasic_core.engine import (
|
|
12
|
+
GATE_TABLE,
|
|
13
|
+
MAX_INCLUDE_DEPTH,
|
|
14
|
+
RE_OPEN, RE_CLOSE, RE_PRINT_FILE, RE_INPUT_FILE, RE_LPRINT,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FileIOMixin:
|
|
19
|
+
"""File I/O commands for QBasicTerminal.
|
|
20
|
+
|
|
21
|
+
Requires: TerminalProtocol — uses self.program, self.num_qubits, self.shots,
|
|
22
|
+
self.sim_method, self.sim_device, self._custom_gates, self.subroutines,
|
|
23
|
+
self.registers, self.variables, self.last_counts, self.last_circuit,
|
|
24
|
+
self._include_depth, self._sanitize_path(), self.cmd_new(), self.process().
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def cmd_save(self, rest: str) -> None:
|
|
28
|
+
"""SAVE <filename> — write program, config, and definitions to a .qb file."""
|
|
29
|
+
if not rest:
|
|
30
|
+
self.io.writeln("?USAGE: SAVE <filename>")
|
|
31
|
+
return
|
|
32
|
+
try:
|
|
33
|
+
path = self._sanitize_path(rest)
|
|
34
|
+
except ValueError as e:
|
|
35
|
+
self.io.writeln(f"?SAVE ERROR: {e}")
|
|
36
|
+
return
|
|
37
|
+
checked = self._check_agent_path(path, "SAVE")
|
|
38
|
+
if checked is None:
|
|
39
|
+
return
|
|
40
|
+
path = checked
|
|
41
|
+
if not path.endswith('.qb'):
|
|
42
|
+
path += '.qb'
|
|
43
|
+
try:
|
|
44
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
45
|
+
# Save config
|
|
46
|
+
f.write(f"QUBITS {self.num_qubits}\n")
|
|
47
|
+
f.write(f"SHOTS {self.shots}\n")
|
|
48
|
+
if self.sim_method != 'automatic':
|
|
49
|
+
f.write(f"METHOD {self.sim_method}\n")
|
|
50
|
+
if self.sim_device != 'CPU':
|
|
51
|
+
f.write(f"METHOD {self.sim_device}\n")
|
|
52
|
+
# Save custom gates as executable UNITARY commands
|
|
53
|
+
for name, matrix in self._custom_gates.items():
|
|
54
|
+
rows = matrix.tolist()
|
|
55
|
+
mat_str = '[' + ','.join(
|
|
56
|
+
'[' + ','.join(str(v) for v in row) + ']'
|
|
57
|
+
for row in rows
|
|
58
|
+
) + ']'
|
|
59
|
+
f.write(f"UNITARY {name} = {mat_str}\n")
|
|
60
|
+
# Save subroutines
|
|
61
|
+
for name, sub in self.subroutines.items():
|
|
62
|
+
if isinstance(sub, list):
|
|
63
|
+
f.write(f"DEF {name} = {' : '.join(sub)}\n")
|
|
64
|
+
else:
|
|
65
|
+
params = f"({', '.join(sub['params'])})" if sub['params'] else ""
|
|
66
|
+
f.write(f"DEF {name}{params} = {' : '.join(sub['body'])}\n")
|
|
67
|
+
# Save registers
|
|
68
|
+
for name, (start, size) in self.registers.items():
|
|
69
|
+
f.write(f"REG {name} {size}\n")
|
|
70
|
+
# Save variables
|
|
71
|
+
for name, val in self.variables.items():
|
|
72
|
+
f.write(f"LET {name} = {val}\n")
|
|
73
|
+
# Save program
|
|
74
|
+
for num in sorted(self.program.keys()):
|
|
75
|
+
f.write(f"{num} {self.program[num]}\n")
|
|
76
|
+
self.io.writeln(f"SAVED {len(self.program)} lines to {path}")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
self.io.writeln(f"?SAVE ERROR: {e}")
|
|
79
|
+
|
|
80
|
+
def cmd_load(self, rest: str) -> None:
|
|
81
|
+
"""LOAD <filename> — clear state and load a .qb program file."""
|
|
82
|
+
if not rest:
|
|
83
|
+
self.io.writeln("?USAGE: LOAD <filename>")
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
path = self._sanitize_path(rest)
|
|
87
|
+
except ValueError as e:
|
|
88
|
+
self.io.writeln(f"?LOAD ERROR: {e}")
|
|
89
|
+
return
|
|
90
|
+
if os.path.isdir(path):
|
|
91
|
+
self.io.writeln(f"?{path} is a directory, not a file")
|
|
92
|
+
return
|
|
93
|
+
if not path.endswith('.qb') and not os.path.isfile(path):
|
|
94
|
+
path += '.qb'
|
|
95
|
+
if not os.path.isfile(path):
|
|
96
|
+
self.io.writeln(f"?FILE NOT FOUND: {path}")
|
|
97
|
+
return
|
|
98
|
+
try:
|
|
99
|
+
prev_qubits = self.num_qubits
|
|
100
|
+
prev_shots = self.shots
|
|
101
|
+
self.cmd_new(silent=True)
|
|
102
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
103
|
+
for line in f:
|
|
104
|
+
line = line.rstrip('\n\r')
|
|
105
|
+
if line and not line.startswith('#'):
|
|
106
|
+
self.process(line, track_undo=False)
|
|
107
|
+
self.io.writeln(f"LOADED {path} ({len(self.program)} lines)")
|
|
108
|
+
if self.num_qubits != prev_qubits:
|
|
109
|
+
self.io.writeln(f" (QUBITS changed: {prev_qubits} -> {self.num_qubits})")
|
|
110
|
+
if self.shots != prev_shots:
|
|
111
|
+
self.io.writeln(f" (SHOTS changed: {prev_shots} -> {self.shots})")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
self.io.writeln(f"?LOAD ERROR: {e}")
|
|
114
|
+
|
|
115
|
+
def cmd_include(self, rest: str) -> None:
|
|
116
|
+
"""INCLUDE file.qb — merge another program's lines into current.
|
|
117
|
+
|
|
118
|
+
Depth-limited to MAX_INCLUDE_DEPTH to prevent infinite recursion.
|
|
119
|
+
Cycle detection via _include_stack prevents A->B->A loops.
|
|
120
|
+
SAVE, LOAD, and INCLUDE are blocked inside included files so that
|
|
121
|
+
an included script cannot write arbitrary files or recurse further
|
|
122
|
+
without the user's direct interaction.
|
|
123
|
+
"""
|
|
124
|
+
if self._include_depth >= MAX_INCLUDE_DEPTH:
|
|
125
|
+
self.io.writeln(f"?INCLUDE DEPTH LIMIT ({MAX_INCLUDE_DEPTH}) — possible recursion")
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
path = self._sanitize_path(rest)
|
|
129
|
+
except ValueError as e:
|
|
130
|
+
self.io.writeln(f"?INCLUDE ERROR: {e}")
|
|
131
|
+
return
|
|
132
|
+
if not path.endswith('.qb') and not os.path.isfile(path):
|
|
133
|
+
path += '.qb'
|
|
134
|
+
if not os.path.isfile(path):
|
|
135
|
+
self.io.writeln(f"?FILE NOT FOUND: {path}")
|
|
136
|
+
return
|
|
137
|
+
resolved = os.path.realpath(path)
|
|
138
|
+
if resolved in self._include_stack:
|
|
139
|
+
self.io.writeln(f"?INCLUDE CYCLE: {path} already in include chain")
|
|
140
|
+
return
|
|
141
|
+
count = 0
|
|
142
|
+
self._include_depth += 1
|
|
143
|
+
self._include_stack.append(resolved)
|
|
144
|
+
try:
|
|
145
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
146
|
+
for line in f:
|
|
147
|
+
line = line.rstrip('\n\r')
|
|
148
|
+
if not line or line.startswith('#'):
|
|
149
|
+
continue
|
|
150
|
+
# Block file-writing commands inside includes
|
|
151
|
+
first_word = line.split(None, 1)[0].upper() if line.strip() else ''
|
|
152
|
+
if first_word in ('SAVE', 'LOAD', 'EXPORT', 'CSV'):
|
|
153
|
+
self.io.writeln(f" ?BLOCKED IN INCLUDE: {first_word}")
|
|
154
|
+
continue
|
|
155
|
+
self.process(line, track_undo=False)
|
|
156
|
+
count += 1
|
|
157
|
+
finally:
|
|
158
|
+
self._include_stack.pop()
|
|
159
|
+
self._include_depth -= 1
|
|
160
|
+
self.io.writeln(f"INCLUDED {path} ({count} lines)")
|
|
161
|
+
|
|
162
|
+
def cmd_import(self, rest: str) -> None:
|
|
163
|
+
"""IMPORT "file.qb" — load subroutine definitions with namespace prefix.
|
|
164
|
+
|
|
165
|
+
DEFs in the imported file are prefixed with the module name.
|
|
166
|
+
E.g., IMPORT "math.qb" makes DEF ROT available as MATH.ROT.
|
|
167
|
+
"""
|
|
168
|
+
from qbasic_core.engine import RE_IMPORT
|
|
169
|
+
m = RE_IMPORT.match(f"IMPORT {rest}") if not rest.startswith('IMPORT') else RE_IMPORT.match(rest)
|
|
170
|
+
path = rest.strip().strip('"').strip("'")
|
|
171
|
+
if not path:
|
|
172
|
+
self.io.writeln('?USAGE: IMPORT "filename"')
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
path = self._sanitize_path(path)
|
|
176
|
+
except ValueError as e:
|
|
177
|
+
self.io.writeln(f"?IMPORT ERROR: {e}")
|
|
178
|
+
return
|
|
179
|
+
if not path.endswith('.qb') and not os.path.isfile(path):
|
|
180
|
+
path += '.qb'
|
|
181
|
+
if not os.path.isfile(path):
|
|
182
|
+
self.io.writeln(f"?FILE NOT FOUND: {path}")
|
|
183
|
+
return
|
|
184
|
+
# Module name from filename (without extension)
|
|
185
|
+
mod_name = os.path.splitext(os.path.basename(path))[0].upper()
|
|
186
|
+
# Parse the file for DEF statements
|
|
187
|
+
import_count = 0
|
|
188
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
189
|
+
for line in f:
|
|
190
|
+
line = line.rstrip('\n\r').strip()
|
|
191
|
+
if not line or line.startswith('#'):
|
|
192
|
+
continue
|
|
193
|
+
# Look for DEF name = body
|
|
194
|
+
upper = line.upper()
|
|
195
|
+
if upper.startswith('DEF ') and '=' in line and not upper.startswith('DEF BEGIN'):
|
|
196
|
+
from qbasic_core.engine import RE_DEF_SINGLE
|
|
197
|
+
dm = RE_DEF_SINGLE.match(line[4:].strip())
|
|
198
|
+
if dm:
|
|
199
|
+
name = dm.group(1).upper()
|
|
200
|
+
params = [p.strip() for p in dm.group(2).split(',')] if dm.group(2) else []
|
|
201
|
+
body = [s.strip() for s in dm.group(3).split(':') if s.strip()]
|
|
202
|
+
qualified = f"{mod_name}.{name}"
|
|
203
|
+
self.subroutines[qualified] = {'body': body, 'params': params}
|
|
204
|
+
import_count += 1
|
|
205
|
+
self.io.writeln(f"IMPORTED {mod_name} ({import_count} definitions from {path})")
|
|
206
|
+
|
|
207
|
+
def cmd_dir(self, rest: str = '') -> None:
|
|
208
|
+
"""List .qb files in current or specified directory."""
|
|
209
|
+
if rest.strip():
|
|
210
|
+
path = self._sanitize_path(rest)
|
|
211
|
+
else:
|
|
212
|
+
path = '.'
|
|
213
|
+
try:
|
|
214
|
+
files = [f for f in os.listdir(path) if f.endswith('.qb')]
|
|
215
|
+
if files:
|
|
216
|
+
for f in sorted(files):
|
|
217
|
+
size = os.path.getsize(os.path.join(path, f))
|
|
218
|
+
self.io.writeln(f" {f:<30} {size:>6} bytes")
|
|
219
|
+
else:
|
|
220
|
+
self.io.writeln(" No .qb files found")
|
|
221
|
+
except Exception as e:
|
|
222
|
+
self.io.writeln(f"?DIR ERROR: {e}")
|
|
223
|
+
|
|
224
|
+
def cmd_export(self, rest: str) -> None:
|
|
225
|
+
"""EXPORT [filename] — export circuit as OpenQASM 3.0."""
|
|
226
|
+
if self.last_circuit is None:
|
|
227
|
+
self.io.writeln("?NO CIRCUIT — RUN first")
|
|
228
|
+
return
|
|
229
|
+
qasm = None
|
|
230
|
+
errors = []
|
|
231
|
+
try:
|
|
232
|
+
from qiskit.qasm3 import dumps
|
|
233
|
+
qasm = dumps(self.last_circuit)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
errors.append(str(e))
|
|
236
|
+
if qasm is None:
|
|
237
|
+
self.io.writeln("?EXPORT: OpenQASM export not available.")
|
|
238
|
+
for err in errors:
|
|
239
|
+
self.io.writeln(f" {err}")
|
|
240
|
+
return
|
|
241
|
+
if rest.strip():
|
|
242
|
+
try:
|
|
243
|
+
path = self._sanitize_path(rest)
|
|
244
|
+
except ValueError as e:
|
|
245
|
+
self.io.writeln(f"?EXPORT ERROR: {e}")
|
|
246
|
+
return
|
|
247
|
+
checked = self._check_agent_path(path, "EXPORT")
|
|
248
|
+
if checked is None:
|
|
249
|
+
return
|
|
250
|
+
path = checked
|
|
251
|
+
if os.path.exists(path):
|
|
252
|
+
self.io.writeln(f" (overwriting {path})")
|
|
253
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
254
|
+
f.write(qasm)
|
|
255
|
+
self.io.writeln(f"EXPORTED to {path} (OpenQASM 3.0)")
|
|
256
|
+
else:
|
|
257
|
+
self.io.writeln(qasm)
|
|
258
|
+
|
|
259
|
+
def cmd_csv(self, rest: str) -> None:
|
|
260
|
+
"""CSV [filename] — export last results to CSV."""
|
|
261
|
+
if self.last_counts is None:
|
|
262
|
+
self.io.writeln("?NO RESULTS — RUN first")
|
|
263
|
+
return
|
|
264
|
+
total = sum(self.last_counts.values())
|
|
265
|
+
lines = ["state,count,probability"]
|
|
266
|
+
for state, count in sorted(self.last_counts.items(), key=lambda x: -x[1]):
|
|
267
|
+
lines.append(f"{state},{count},{count/total:.6f}")
|
|
268
|
+
if rest.strip():
|
|
269
|
+
try:
|
|
270
|
+
path = self._sanitize_path(rest)
|
|
271
|
+
except ValueError as e:
|
|
272
|
+
self.io.writeln(f"?CSV ERROR: {e}")
|
|
273
|
+
return
|
|
274
|
+
checked = self._check_agent_path(path, "CSV")
|
|
275
|
+
if checked is None:
|
|
276
|
+
return
|
|
277
|
+
path = checked
|
|
278
|
+
if os.path.exists(path):
|
|
279
|
+
self.io.writeln(f" (overwriting {path})")
|
|
280
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
281
|
+
f.write('\n'.join(lines) + '\n')
|
|
282
|
+
self.io.writeln(f"EXPORTED {len(self.last_counts)} states to {path}")
|
|
283
|
+
else:
|
|
284
|
+
for line in lines[:20]:
|
|
285
|
+
self.io.writeln(f" {line}")
|
|
286
|
+
if len(lines) > 20:
|
|
287
|
+
self.io.writeln(f" ... ({len(lines)-20} more)")
|
|
288
|
+
|
|
289
|
+
# ── File handles: OPEN, CLOSE, PRINT#, INPUT#, EOF, LPRINT ────────
|
|
290
|
+
|
|
291
|
+
def _init_file_handles(self) -> None:
|
|
292
|
+
self._file_handles: dict[int, Any] = {}
|
|
293
|
+
self._lprint_path: str | None = None
|
|
294
|
+
|
|
295
|
+
def _check_agent_path(self, path: str, cmd: str) -> str | None:
|
|
296
|
+
"""In agent mode, restrict paths to cwd. Returns resolved path or None on error.
|
|
297
|
+
|
|
298
|
+
Uses pathlib is_relative_to for safe containment check instead of
|
|
299
|
+
string prefix matching, which can be bypassed via symlinks or
|
|
300
|
+
crafted path components that share a common prefix string.
|
|
301
|
+
"""
|
|
302
|
+
if not self.agent_mode:
|
|
303
|
+
return path
|
|
304
|
+
from pathlib import Path
|
|
305
|
+
resolved = Path(os.path.realpath(os.path.join(os.getcwd(), path)))
|
|
306
|
+
cwd_real = Path(os.path.realpath(os.getcwd()))
|
|
307
|
+
if not resolved.is_relative_to(cwd_real):
|
|
308
|
+
self.io.writeln(f"?{cmd} BLOCKED: path escapes working directory in agent mode")
|
|
309
|
+
return None
|
|
310
|
+
return str(resolved)
|
|
311
|
+
|
|
312
|
+
def cmd_open(self, rest: str) -> None:
|
|
313
|
+
"""OPEN file FOR INPUT|OUTPUT|APPEND AS #n [ENCODING "enc"]"""
|
|
314
|
+
m = RE_OPEN.match(f"OPEN {rest}")
|
|
315
|
+
if not m:
|
|
316
|
+
self.io.writeln('?USAGE: OPEN "file" FOR INPUT|OUTPUT|APPEND AS #n [ENCODING "enc"]')
|
|
317
|
+
return
|
|
318
|
+
path, mode_str, handle = m.group(1).strip(), m.group(2).upper(), int(m.group(3))
|
|
319
|
+
encoding = m.group(4).strip() if m.group(4) else 'utf-8'
|
|
320
|
+
try:
|
|
321
|
+
path = self._sanitize_path(path)
|
|
322
|
+
except ValueError as e:
|
|
323
|
+
self.io.writeln(f"?OPEN ERROR: {e}")
|
|
324
|
+
return
|
|
325
|
+
checked = self._check_agent_path(path, "OPEN")
|
|
326
|
+
if checked is None:
|
|
327
|
+
return
|
|
328
|
+
path = checked
|
|
329
|
+
mode_map = {'INPUT': 'r', 'OUTPUT': 'w', 'APPEND': 'a', 'RANDOM': 'r+'}
|
|
330
|
+
mode = mode_map.get(mode_str, 'r')
|
|
331
|
+
try:
|
|
332
|
+
if encoding.lower() == 'binary':
|
|
333
|
+
bin_mode = mode + 'b'
|
|
334
|
+
if mode_str == 'RANDOM' and not os.path.isfile(path):
|
|
335
|
+
open(path, 'wb').close()
|
|
336
|
+
if mode_str == 'RANDOM' and os.path.isfile(path):
|
|
337
|
+
self.io.writeln(f" (opening existing file {path} for random access)")
|
|
338
|
+
self._file_handles[handle] = open(path, bin_mode)
|
|
339
|
+
else:
|
|
340
|
+
if mode_str == 'RANDOM' and not os.path.isfile(path):
|
|
341
|
+
open(path, 'w', encoding=encoding).close()
|
|
342
|
+
if mode_str == 'RANDOM' and os.path.isfile(path):
|
|
343
|
+
self.io.writeln(f" (opening existing file {path} for random access)")
|
|
344
|
+
self._file_handles[handle] = open(path, mode, encoding=encoding)
|
|
345
|
+
self.io.writeln(f"OPENED #{handle} ({path}, {mode_str})")
|
|
346
|
+
except Exception as e:
|
|
347
|
+
self.io.writeln(f"?OPEN ERROR: {e}")
|
|
348
|
+
|
|
349
|
+
def cmd_close(self, rest: str) -> None:
|
|
350
|
+
"""CLOSE #n — close a file handle."""
|
|
351
|
+
m = RE_CLOSE.match(f"CLOSE {rest}")
|
|
352
|
+
if not m:
|
|
353
|
+
self.io.writeln("?USAGE: CLOSE #n")
|
|
354
|
+
return
|
|
355
|
+
handle = int(m.group(1))
|
|
356
|
+
if handle in self._file_handles:
|
|
357
|
+
self._file_handles[handle].close()
|
|
358
|
+
del self._file_handles[handle]
|
|
359
|
+
self.io.writeln(f"CLOSED #{handle}")
|
|
360
|
+
else:
|
|
361
|
+
self.io.writeln(f"?HANDLE #{handle} NOT OPEN")
|
|
362
|
+
|
|
363
|
+
def _exec_print_file(self, stmt: str, run_vars: dict[str, Any]) -> bool:
|
|
364
|
+
"""Handle PRINT #n, data during execution."""
|
|
365
|
+
m = RE_PRINT_FILE.match(stmt)
|
|
366
|
+
if not m:
|
|
367
|
+
return False
|
|
368
|
+
handle = int(m.group(1))
|
|
369
|
+
data = m.group(2).strip()
|
|
370
|
+
if handle not in self._file_handles:
|
|
371
|
+
self.io.writeln(f"?HANDLE #{handle} NOT OPEN")
|
|
372
|
+
return True
|
|
373
|
+
f = self._file_handles[handle]
|
|
374
|
+
fmode = getattr(f, 'mode', '')
|
|
375
|
+
if fmode.startswith('r') and '+' not in fmode:
|
|
376
|
+
self.io.writeln(f"?HANDLE #{handle} NOT OPEN FOR WRITING")
|
|
377
|
+
return True
|
|
378
|
+
# Evaluate data
|
|
379
|
+
if (data.startswith('"') and data.endswith('"')):
|
|
380
|
+
f.write(data[1:-1] + '\n')
|
|
381
|
+
else:
|
|
382
|
+
try:
|
|
383
|
+
val = self._eval_with_vars(data, run_vars) if run_vars else self.eval_expr(data)
|
|
384
|
+
f.write(str(val) + '\n')
|
|
385
|
+
except Exception:
|
|
386
|
+
f.write(data + '\n')
|
|
387
|
+
f.flush()
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
def _exec_input_file(self, stmt: str, run_vars: dict[str, Any]) -> bool:
|
|
391
|
+
"""Handle INPUT #n, var during execution."""
|
|
392
|
+
m = RE_INPUT_FILE.match(stmt)
|
|
393
|
+
if not m:
|
|
394
|
+
return False
|
|
395
|
+
handle = int(m.group(1))
|
|
396
|
+
var = m.group(2)
|
|
397
|
+
if handle not in self._file_handles:
|
|
398
|
+
self.io.writeln(f"?HANDLE #{handle} NOT OPEN")
|
|
399
|
+
return True
|
|
400
|
+
f = self._file_handles[handle]
|
|
401
|
+
fmode = getattr(f, 'mode', '')
|
|
402
|
+
if fmode.startswith(('w', 'a')) and '+' not in fmode:
|
|
403
|
+
self.io.writeln(f"?HANDLE #{handle} NOT OPEN FOR READING")
|
|
404
|
+
return True
|
|
405
|
+
line = f.readline()
|
|
406
|
+
if not line:
|
|
407
|
+
run_vars[var] = 0
|
|
408
|
+
self.variables[var] = 0
|
|
409
|
+
else:
|
|
410
|
+
line = line.strip()
|
|
411
|
+
if var.endswith('$'):
|
|
412
|
+
run_vars[var] = line
|
|
413
|
+
self.variables[var] = line
|
|
414
|
+
else:
|
|
415
|
+
try:
|
|
416
|
+
val = float(line) if '.' in line else int(line)
|
|
417
|
+
except ValueError:
|
|
418
|
+
val = line
|
|
419
|
+
run_vars[var] = val
|
|
420
|
+
self.variables[var] = val
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
def _exec_lprint(self, stmt: str, run_vars: dict[str, Any]) -> bool:
|
|
424
|
+
"""Handle LPRINT data — output to log file or stderr."""
|
|
425
|
+
m = RE_LPRINT.match(stmt)
|
|
426
|
+
if not m:
|
|
427
|
+
return False
|
|
428
|
+
data = m.group(1).strip()
|
|
429
|
+
if (data.startswith('"') and data.endswith('"')):
|
|
430
|
+
text = data[1:-1]
|
|
431
|
+
else:
|
|
432
|
+
try:
|
|
433
|
+
text = str(self._eval_with_vars(data, run_vars) if run_vars else self.eval_expr(data))
|
|
434
|
+
except Exception:
|
|
435
|
+
text = data
|
|
436
|
+
if self._lprint_path:
|
|
437
|
+
with open(self._lprint_path, 'a', encoding='utf-8') as f:
|
|
438
|
+
f.write(text + '\n')
|
|
439
|
+
else:
|
|
440
|
+
print(text, file=sys.stderr)
|
|
441
|
+
return True
|
|
442
|
+
|
|
443
|
+
def _eof(self, handle: float) -> float:
|
|
444
|
+
"""EOF(n) — return 1 if at end of file, 0 otherwise."""
|
|
445
|
+
h = int(handle)
|
|
446
|
+
if h not in self._file_handles:
|
|
447
|
+
return 1.0
|
|
448
|
+
f = self._file_handles[h]
|
|
449
|
+
pos = f.tell()
|
|
450
|
+
try:
|
|
451
|
+
ch = f.read(1)
|
|
452
|
+
if not ch:
|
|
453
|
+
return 1.0
|
|
454
|
+
f.seek(pos)
|
|
455
|
+
except (UnicodeDecodeError, Exception):
|
|
456
|
+
return 1.0
|
|
457
|
+
return 0.0
|