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.
Files changed (48) hide show
  1. qbasic.py +113 -0
  2. qbasic_core/__init__.py +80 -0
  3. qbasic_core/__main__.py +6 -0
  4. qbasic_core/analysis.py +179 -0
  5. qbasic_core/backend.py +76 -0
  6. qbasic_core/classic.py +419 -0
  7. qbasic_core/control_flow.py +576 -0
  8. qbasic_core/debug.py +327 -0
  9. qbasic_core/demos.py +356 -0
  10. qbasic_core/display.py +274 -0
  11. qbasic_core/engine.py +126 -0
  12. qbasic_core/engine_state.py +109 -0
  13. qbasic_core/errors.py +37 -0
  14. qbasic_core/exec_context.py +24 -0
  15. qbasic_core/executor.py +861 -0
  16. qbasic_core/expression.py +228 -0
  17. qbasic_core/file_io.py +457 -0
  18. qbasic_core/gates.py +284 -0
  19. qbasic_core/help_text.py +167 -0
  20. qbasic_core/io_protocol.py +33 -0
  21. qbasic_core/locc.py +10 -0
  22. qbasic_core/locc_commands.py +221 -0
  23. qbasic_core/locc_display.py +61 -0
  24. qbasic_core/locc_engine.py +195 -0
  25. qbasic_core/locc_execution.py +389 -0
  26. qbasic_core/memory.py +369 -0
  27. qbasic_core/mock_backend.py +66 -0
  28. qbasic_core/noise_mixin.py +96 -0
  29. qbasic_core/parser.py +564 -0
  30. qbasic_core/patterns.py +186 -0
  31. qbasic_core/profiler.py +156 -0
  32. qbasic_core/program_mgmt.py +369 -0
  33. qbasic_core/protocol.py +77 -0
  34. qbasic_core/py.typed +0 -0
  35. qbasic_core/scope.py +74 -0
  36. qbasic_core/screen.py +115 -0
  37. qbasic_core/state_display.py +60 -0
  38. qbasic_core/statements.py +387 -0
  39. qbasic_core/strings.py +107 -0
  40. qbasic_core/subs.py +261 -0
  41. qbasic_core/sweep.py +82 -0
  42. qbasic_core/terminal.py +1697 -0
  43. qubasic-0.1.0.dist-info/METADATA +736 -0
  44. qubasic-0.1.0.dist-info/RECORD +48 -0
  45. qubasic-0.1.0.dist-info/WHEEL +5 -0
  46. qubasic-0.1.0.dist-info/entry_points.txt +2 -0
  47. qubasic-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. 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