qubasic 0.6.1__tar.gz → 0.6.4__tar.gz

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 (61) hide show
  1. {qubasic-0.6.1 → qubasic-0.6.4}/CHANGELOG.md +22 -0
  2. {qubasic-0.6.1/qubasic.egg-info → qubasic-0.6.4}/PKG-INFO +2 -2
  3. {qubasic-0.6.1 → qubasic-0.6.4}/README.md +1 -1
  4. {qubasic-0.6.1 → qubasic-0.6.4}/pyproject.toml +1 -1
  5. {qubasic-0.6.1 → qubasic-0.6.4/qubasic.egg-info}/PKG-INFO +2 -2
  6. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/__init__.py +1 -1
  7. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/cli.py +10 -11
  8. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/control_flow.py +126 -38
  9. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/engine_state.py +14 -0
  10. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/executor.py +91 -20
  11. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/file_io.py +0 -2
  12. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/locc_engine.py +19 -4
  13. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/locc_execution.py +1 -2
  14. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/memory.py +8 -2
  15. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/parser.py +8 -0
  16. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/terminal.py +27 -7
  17. {qubasic-0.6.1 → qubasic-0.6.4}/tests/test_features.py +36 -0
  18. {qubasic-0.6.1 → qubasic-0.6.4}/tests/test_qubasic.py +86 -0
  19. {qubasic-0.6.1 → qubasic-0.6.4}/LICENSE +0 -0
  20. {qubasic-0.6.1 → qubasic-0.6.4}/MANIFEST.in +0 -0
  21. {qubasic-0.6.1 → qubasic-0.6.4}/examples/bell.qb +0 -0
  22. {qubasic-0.6.1 → qubasic-0.6.4}/examples/grover3.qb +0 -0
  23. {qubasic-0.6.1 → qubasic-0.6.4}/examples/locc_teleport.qb +0 -0
  24. {qubasic-0.6.1 → qubasic-0.6.4}/examples/sweep_rx.qb +0 -0
  25. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic.egg-info/SOURCES.txt +0 -0
  26. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic.egg-info/dependency_links.txt +0 -0
  27. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic.egg-info/entry_points.txt +0 -0
  28. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic.egg-info/requires.txt +0 -0
  29. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic.egg-info/top_level.txt +0 -0
  30. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/__main__.py +0 -0
  31. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/analysis.py +0 -0
  32. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/backend.py +0 -0
  33. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/classic.py +0 -0
  34. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/debug.py +0 -0
  35. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/demos.py +0 -0
  36. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/display.py +0 -0
  37. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/engine.py +0 -0
  38. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/errors.py +0 -0
  39. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/exec_context.py +0 -0
  40. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/expression.py +0 -0
  41. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/gates.py +0 -0
  42. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/help_text.py +0 -0
  43. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/io_protocol.py +0 -0
  44. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/locc.py +0 -0
  45. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/locc_commands.py +0 -0
  46. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/locc_display.py +0 -0
  47. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/mock_backend.py +0 -0
  48. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/noise_mixin.py +0 -0
  49. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/patterns.py +0 -0
  50. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/profiler.py +0 -0
  51. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/program_mgmt.py +0 -0
  52. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/protocol.py +0 -0
  53. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/qol.py +0 -0
  54. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/scope.py +0 -0
  55. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/screen.py +0 -0
  56. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/state_display.py +0 -0
  57. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/statements.py +0 -0
  58. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/strings.py +0 -0
  59. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/subs.py +0 -0
  60. {qubasic-0.6.1 → qubasic-0.6.4}/qubasic_core/sweep.py +0 -0
  61. {qubasic-0.6.1 → qubasic-0.6.4}/setup.cfg +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.4 (2026-06-19)
4
+
5
+ ### Fixed
6
+ - Immediate-mode `PRINT` at the REPL no longer prints a spurious statevector after its output. `PRINT` is not a dispatch-table command, so it fell through to the immediate gate path, which always dumped `|psi>`. That path now skips the dump when the typed line added no circuit operations, so gate and subroutine entries still show state while `PRINT`, `MEASURE`, and other classical statements do not.
7
+ - `--quiet` now prints results with the banner suppressed, as documented. The results branch was unreachable (guarded by `elif not quiet` inside an `if quiet or json_mode` block), so `--quiet` previously produced no output at all.
8
+ - A `MEASURE` reachable only inside a `DEF`/`SUB` body, an `IF ... THEN/ELSE` clause, or a colon compound is now detected, so the program takes the shots path (and `qubasic script.qb` auto-runs) instead of the no-measure statevector path.
9
+ - `LET m(i, j) = x` writes a multi-dimensional array element using the same flat-stride convention the expression-side accessor reads, so multi-dimensional reads and writes agree.
10
+ - Immediate-mode `LET a(i) = <expr>` at the REPL assigns the array element, matching the in-program `LET` (previously only the in-program form handled array targets).
11
+
12
+ ## 0.6.3 (2026-06-18)
13
+
14
+ ### Fixed
15
+ - Colon-compound `@register` lines in LOCC mode now apply every gate. `@A H 0 : @A CX 0,1` (and the inherited form `@A H 0 : CX 0,1`) were captured as a single statement and mis-tokenized, so all but the first gate were silently dropped. The parser now splits them into per-statement parts with register inheritance, and the immediate-mode REPL path does the same.
16
+ - `SAVE_EXPECT`, `SAVE_PROBS`, and `SAVE_AMPS` no longer overwrite their target variable with 0 at circuit-build time. The placeholder keeps any prior value, so a program that re-runs with the SAVE line still present can read the previous result during the build pass (the post-run extraction then fills in the fresh value).
17
+ - `PRINT` honors `;` and `,` between multiple items: `PRINT "S ="; x` concatenates and `PRINT a, b` advances to the next 14-column zone. Separators inside quoted strings and inside call parentheses (`PRINT LEFT$(s$, 3)`) are no longer treated as item breaks.
18
+ - `PRINT` variable substitution no longer rewrites identifiers inside quoted string literals, so a label such as `PRINT "value of S"` is emitted verbatim even when `S` is a defined variable.
19
+
20
+ ## 0.6.2 (2026-06-18)
21
+
22
+ ### Fixed
23
+ - Changing the qubit count after a run (via `QUBITS n` or `POKE $D000`) now invalidates the cached statevector, so `MAP`, a `PEEK` of the qubit-state block, and `BLOCH` no longer reshape a stale statevector to the new qubit count and crash.
24
+
3
25
  ## 0.6.1 (2026-06-18)
4
26
 
5
27
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qubasic
3
- Version: 0.6.1
3
+ Version: 0.6.4
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -661,7 +661,7 @@ QBASIC detects available RAM, estimates per-instance memory, and reports maximum
661
661
  ### Simulation method selection
662
662
  - **automatic**: stabilizer for Clifford-only circuits, MPS for >28 qubits, statevector otherwise
663
663
  - **stabilizer**: polynomial-time for Clifford circuits (H, S, CX, SWAP, etc.)
664
- - **matrix_product_state**: scales to 50+ qubits for low-entanglement circuits
664
+ - **matrix_product_state**: memory-efficient for low-entanglement circuits; handles the full 32-qubit range that would exhaust statevector
665
665
  - **extended_stabilizer**: approximate simulation for near-Clifford circuits
666
666
  - **statevector**: exact, limited by RAM (~28 qubits on 16GB)
667
667
  - **density_matrix**: includes mixed states, ~14 qubits on 16GB
@@ -628,7 +628,7 @@ QBASIC detects available RAM, estimates per-instance memory, and reports maximum
628
628
  ### Simulation method selection
629
629
  - **automatic**: stabilizer for Clifford-only circuits, MPS for >28 qubits, statevector otherwise
630
630
  - **stabilizer**: polynomial-time for Clifford circuits (H, S, CX, SWAP, etc.)
631
- - **matrix_product_state**: scales to 50+ qubits for low-entanglement circuits
631
+ - **matrix_product_state**: memory-efficient for low-entanglement circuits; handles the full 32-qubit range that would exhaust statevector
632
632
  - **extended_stabilizer**: approximate simulation for near-Clifford circuits
633
633
  - **statevector**: exact, limited by RAM (~28 qubits on 16GB)
634
634
  - **density_matrix**: includes mixed states, ~14 qubits on 16GB
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qubasic"
7
- version = "0.6.1"
7
+ version = "0.6.4"
8
8
  description = "Quantum BASIC Interactive Terminal"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qubasic
3
- Version: 0.6.1
3
+ Version: 0.6.4
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -661,7 +661,7 @@ QBASIC detects available RAM, estimates per-instance memory, and reports maximum
661
661
  ### Simulation method selection
662
662
  - **automatic**: stabilizer for Clifford-only circuits, MPS for >28 qubits, statevector otherwise
663
663
  - **stabilizer**: polynomial-time for Clifford circuits (H, S, CX, SWAP, etc.)
664
- - **matrix_product_state**: scales to 50+ qubits for low-entanglement circuits
664
+ - **matrix_product_state**: memory-efficient for low-entanglement circuits; handles the full 32-qubit range that would exhaust statevector
665
665
  - **extended_stabilizer**: approximate simulation for near-Clifford circuits
666
666
  - **statevector**: exact, limited by RAM (~28 qubits on 16GB)
667
667
  - **density_matrix**: includes mixed states, ~14 qubits on 16GB
@@ -28,7 +28,7 @@ __all__ = [
28
28
  'GATE_TABLE', 'GATE_ALIASES',
29
29
  ]
30
30
 
31
- __version__ = '0.6.1'
31
+ __version__ = '0.6.4'
32
32
 
33
33
  def __getattr__(name):
34
34
  """Lazy import heavy modules on first access."""
@@ -38,12 +38,8 @@ def run_script(path: str, terminal: 'QBasicTerminal') -> None:
38
38
  ProgramMgmtMixin._load_lines_with_defs(
39
39
  lines, lambda line: terminal.process(line, track_undo=False))
40
40
 
41
- # Auto-run if the program has a MEASURE statement
42
- has_measure = any(
43
- terminal.program.get(ln, '').strip().upper() == 'MEASURE'
44
- for ln in terminal.program
45
- )
46
- if terminal.program and has_measure:
41
+ # Auto-run if the program has a reachable MEASURE (incl. in subs / IF clauses)
42
+ if terminal.program and terminal._program_has_measure(sorted(terminal.program)):
47
43
  terminal.cmd_run()
48
44
 
49
45
 
@@ -125,8 +121,12 @@ def main():
125
121
  print(_json.dumps({'error': err}, indent=2))
126
122
  sys.exit(1)
127
123
  print(_json.dumps(term.result(), indent=2))
128
- elif not quiet:
129
- print(buf.getvalue())
124
+ else:
125
+ # --quiet (no --json): the banner was never emitted into the
126
+ # buffer (only the non-captured path calls print_banner), so the
127
+ # captured text is exactly the results. Print them, matching the
128
+ # documented "suppress banner, output results only" behavior.
129
+ print(buf.getvalue(), end='')
130
130
  if err is not None:
131
131
  print(f"?ERROR: {err}")
132
132
  sys.exit(1)
@@ -134,9 +134,8 @@ def main():
134
134
  term.print_banner()
135
135
  run_script(path, term)
136
136
  # Exit 0 on success; 1 if a measured program produced no counts.
137
- sys.exit(0 if term.last_counts is not None or not any(
138
- term.program.get(ln, '').strip().upper() == 'MEASURE'
139
- for ln in term.program) else 1)
137
+ expects_measure = bool(term.program) and term._program_has_measure(sorted(term.program))
138
+ sys.exit(0 if term.last_counts is not None or not expects_measure else 1)
140
139
  else:
141
140
  term.repl()
142
141
 
@@ -40,12 +40,60 @@ class ControlFlowMixin:
40
40
  # signature for compatibility with _exec_control_flow's argument
41
41
  # passing but is not used by the methods themselves.
42
42
 
43
+ @staticmethod
44
+ def _split_arg_list(s: str) -> list[str]:
45
+ """Split a comma list at top level (outside quotes and brackets)."""
46
+ parts: list[str] = []
47
+ buf = ''
48
+ depth = 0
49
+ quote: str | None = None
50
+ for ch in s:
51
+ if quote:
52
+ buf += ch
53
+ if ch == quote:
54
+ quote = None
55
+ elif ch in ('"', "'"):
56
+ quote = ch
57
+ buf += ch
58
+ elif ch in '([':
59
+ depth += 1
60
+ buf += ch
61
+ elif ch in ')]':
62
+ depth = max(0, depth - 1)
63
+ buf += ch
64
+ elif ch == ',' and depth == 0:
65
+ parts.append(buf)
66
+ buf = ''
67
+ else:
68
+ buf += ch
69
+ if buf.strip():
70
+ parts.append(buf)
71
+ return [p.strip() for p in parts if p.strip()]
72
+
43
73
  def _cf_let_array(self, stmt: str, run_vars: dict[str, Any],
44
74
  parsed: LetArrayStmt) -> tuple[bool, ExecOutcome]:
45
75
  name, idx_expr, val_expr = parsed.name, parsed.index_expr, parsed.value_expr
46
76
  base = getattr(self, '_option_base', 0)
47
- idx = int(self._eval_with_vars(idx_expr, run_vars)) - base
48
77
  val = self._eval_with_vars(val_expr, run_vars)
78
+ parts = self._split_arg_list(idx_expr)
79
+ if len(parts) > 1:
80
+ # Multi-dimensional write: flatten with the same stride convention
81
+ # the expression-side accessor uses, so reads and writes agree.
82
+ dims = getattr(self, '_array_dims', {}).get(name)
83
+ indices = [int(self._eval_with_vars(p, run_vars)) - base for p in parts]
84
+ if any(i < 0 for i in indices):
85
+ raise RuntimeError(f"ARRAY INDEX OUT OF RANGE: {name}({idx_expr})")
86
+ flat, stride = 0, 1
87
+ for k in range(len(indices) - 1, -1, -1):
88
+ flat += indices[k] * stride
89
+ stride *= dims[k] if dims and k < len(dims) else 1
90
+ if name not in self.arrays:
91
+ raise RuntimeError(f"ARRAY NOT DIMENSIONED: {name} (use DIM first)")
92
+ if flat < 0 or flat >= len(self.arrays[name]):
93
+ raise RuntimeError(f"ARRAY INDEX OUT OF RANGE: {name}({idx_expr})")
94
+ self.arrays[name][flat] = val
95
+ return True, ExecResult.ADVANCE
96
+ idx = int(self._eval_with_vars(idx_expr, run_vars)) - base
49
97
  if idx < 0:
50
98
  raise RuntimeError(f"ARRAY INDEX OUT OF RANGE: {name}({idx + base})")
51
99
  if name not in self.arrays:
@@ -63,46 +111,86 @@ class ControlFlowMixin:
63
111
  self.variables[name] = val
64
112
  return True, ExecResult.ADVANCE
65
113
 
66
- def _cf_print(self, stmt: str, run_vars: dict[str, Any],
67
- parsed: PrintStmt) -> tuple[bool, ExecOutcome]:
68
- raw_expr = parsed.expr
69
- text = self._substitute_vars(raw_expr.strip(), run_vars)
70
- # Determine trailing separator: ; suppresses newline, , advances to tab
71
- suppress_newline = raw_expr.rstrip().endswith(';')
72
- tab_advance = raw_expr.rstrip().endswith(',')
73
- if suppress_newline:
74
- text = text.rstrip().removesuffix(';').rstrip()
75
- elif tab_advance:
76
- text = text.rstrip().removesuffix(',').rstrip()
77
- # Evaluate SPC(n) and TAB(n) inline
78
- def _replace_spc(m_spc):
79
- n = int(self._eval_with_vars(m_spc.group(1), run_vars))
80
- return ' ' * max(0, n)
81
- def _replace_tab(m_tab):
82
- n = int(self._eval_with_vars(m_tab.group(1), run_vars))
83
- return ' ' * max(0, n)
84
- text = re.sub(r'\bSPC\s*\(([^)]+)\)', _replace_spc, text, flags=re.IGNORECASE)
85
- text = re.sub(r'\bTAB\s*\(([^)]+)\)', _replace_tab, text, flags=re.IGNORECASE)
86
- # Evaluate the expression
87
- if (text.startswith('"') and text.endswith('"')) or \
88
- (text.startswith("'") and text.endswith("'")):
89
- output = text[1:-1]
90
- else:
114
+ @staticmethod
115
+ def _split_print_items(expr: str) -> list[tuple[str, str]]:
116
+ """Split a PRINT argument list into (item, trailing-separator) pairs.
117
+
118
+ ';' and ',' are recognized only at top level (outside quotes and
119
+ parentheses/brackets), so PRINT LEFT$(s$, 3) stays one item and a comma
120
+ inside a quoted literal is preserved. The separator recorded with each
121
+ item is the one that follows it; '' marks the final item / no trailing
122
+ separator.
123
+ """
124
+ items: list[tuple[str, str]] = []
125
+ buf = ''
126
+ depth = 0
127
+ quote: str | None = None
128
+ for ch in expr:
129
+ if quote:
130
+ buf += ch
131
+ if ch == quote:
132
+ quote = None
133
+ elif ch in ('"', "'"):
134
+ quote = ch
135
+ buf += ch
136
+ elif ch in '([':
137
+ depth += 1
138
+ buf += ch
139
+ elif ch in ')]':
140
+ depth = max(0, depth - 1)
141
+ buf += ch
142
+ elif ch in ';,' and depth == 0:
143
+ items.append((buf.strip(), ch))
144
+ buf = ''
145
+ else:
146
+ buf += ch
147
+ if buf.strip():
148
+ items.append((buf.strip(), ''))
149
+ return items
150
+
151
+ def _eval_print_item(self, item: str, run_vars: dict[str, Any]) -> str:
152
+ """Evaluate a single PRINT item to its display string."""
153
+ item = item.strip()
154
+ if not item:
155
+ return ''
156
+ # Quoted literal: emit verbatim (no substitution, no SPC/TAB).
157
+ if (item[0] == '"' and item[-1] == '"') or (item[0] == "'" and item[-1] == "'"):
158
+ return item[1:-1]
159
+ text = self._substitute_vars(item, run_vars)
160
+
161
+ def _spaces(m):
91
162
  try:
92
- ns = run_vars.as_dict() if hasattr(run_vars, 'as_dict') else dict(run_vars) if not isinstance(run_vars, dict) else run_vars
93
- result = self._safe_eval(text, extra_ns=ns)
94
- output = str(result)
163
+ return ' ' * max(0, int(self._eval_with_vars(m.group(1), run_vars)))
95
164
  except Exception:
96
- output = text
97
- # Output with separator behavior
98
- if suppress_newline:
99
- self.io.write(output)
100
- elif tab_advance:
101
- col = len(output) % 14
102
- padding = 14 - col if col > 0 else 14
103
- self.io.write(output + ' ' * padding)
165
+ return ''
166
+ text = re.sub(r'\bSPC\s*\(([^)]+)\)', _spaces, text, flags=re.IGNORECASE)
167
+ text = re.sub(r'\bTAB\s*\(([^)]+)\)', _spaces, text, flags=re.IGNORECASE)
168
+ if not text.strip():
169
+ return text # standalone SPC/TAB -> whitespace
170
+ try:
171
+ ns = run_vars.as_dict() if hasattr(run_vars, 'as_dict') else (
172
+ run_vars if isinstance(run_vars, dict) else dict(run_vars))
173
+ return str(self._safe_eval(text, extra_ns=ns))
174
+ except Exception:
175
+ return text
176
+
177
+ def _cf_print(self, stmt: str, run_vars: dict[str, Any],
178
+ parsed: PrintStmt) -> tuple[bool, ExecOutcome]:
179
+ items = self._split_print_items(parsed.expr)
180
+ if not items:
181
+ self.io.writeln('') # bare PRINT -> blank line
182
+ return True, ExecResult.ADVANCE
183
+ out = ''
184
+ for item, sep in items:
185
+ out += self._eval_print_item(item, run_vars)
186
+ if sep == ',': # advance to next 14-column zone
187
+ col = len(out) % 14
188
+ out += ' ' * (14 - col if col else 14)
189
+ # A trailing ';' or ',' suppresses the newline (cursor stays on line).
190
+ if items[-1][1] in (';', ','):
191
+ self.io.write(out)
104
192
  else:
105
- self.io.writeln(output)
193
+ self.io.writeln(out)
106
194
  return True, ExecResult.ADVANCE
107
195
 
108
196
  def _cf_goto(self, stmt: str, sorted_lines: list[int],
@@ -119,3 +119,17 @@ class Engine:
119
119
  self._circuit_cache = None
120
120
  self._pending_set_state = None
121
121
  self._poke_state_prep = {}
122
+
123
+ def _invalidate_run_state(self) -> None:
124
+ """Drop cached results that describe a now-stale configuration.
125
+
126
+ Called when the qubit count changes: last_sv, last_counts,
127
+ last_circuit, and the transpile cache all assume the previous
128
+ num_qubits, so reading them afterwards would mislabel (or, for
129
+ the reshape in _peek_qubit/_bloch_vector, crash) the state.
130
+ """
131
+ self.last_counts = None
132
+ self.last_sv = None
133
+ self.last_circuit = None
134
+ self._circuit_cache_key = None
135
+ self._circuit_cache = None
@@ -66,6 +66,41 @@ class ExecutorMixin:
66
66
 
67
67
  # ── Circuit Building ──────────────────────────────────────────────
68
68
 
69
+ def _program_has_measure(self, sorted_lines) -> bool:
70
+ """True if any reachable statement measures.
71
+
72
+ The plain per-line scan misses a MEASURE that only appears inside a DEF
73
+ subroutine body or an IF/THEN-ELSE clause, which left the run on the
74
+ no-measure path with no counts. This recursion-guarded walk follows
75
+ colon compounds, IF clauses, and subroutine bodies so the shots path
76
+ fires whenever a measurement is actually reachable.
77
+ """
78
+ from qubasic_core.statements import MeasureStmt, CompoundStmt, IfThenStmt
79
+ from qubasic_core.parser import parse_stmt
80
+
81
+ def scan(text: str, seen: frozenset) -> bool:
82
+ p = parse_stmt(text)
83
+ if isinstance(p, MeasureStmt):
84
+ return True
85
+ if isinstance(p, CompoundStmt):
86
+ return any(scan(part, seen) for part in p.parts)
87
+ if isinstance(p, IfThenStmt):
88
+ if p.then_clause and scan(p.then_clause, seen):
89
+ return True
90
+ if p.else_clause and scan(p.else_clause, seen):
91
+ return True
92
+ return False
93
+ words = text.strip().split()
94
+ if words:
95
+ word = words[0].upper().split('(')[0]
96
+ if word in self.subroutines and word not in seen:
97
+ sub = self.subroutines[word]
98
+ body = sub['body'] if isinstance(sub, dict) else sub
99
+ return any(scan(b, seen | {word}) for b in body)
100
+ return False
101
+
102
+ return any(scan(self.program[ln], frozenset()) for ln in sorted_lines)
103
+
69
104
  def build_circuit(self) -> tuple['QuantumCircuit', bool]:
70
105
  """Compile program lines into a QuantumCircuit. Returns (circuit, has_measure)."""
71
106
  from qubasic_core.exec_context import ExecContext
@@ -92,7 +127,9 @@ class ExecutorMixin:
92
127
  qc=qc,
93
128
  backend=backend,
94
129
  )
95
- has_measure = False
130
+ # Reachability scan catches MEASURE inside subroutines and IF clauses,
131
+ # not just literal top-level MEASURE lines.
132
+ has_measure = self._program_has_measure(ctx.sorted_lines)
96
133
  self._on_measure_fired = False
97
134
 
98
135
  while ctx.ip < len(ctx.sorted_lines):
@@ -328,20 +365,42 @@ class ExecutorMixin:
328
365
  set(self.subroutines.keys()) |
329
366
  set(self._custom_gates.keys())
330
367
  )
331
- # Tokenize: split on word boundaries, preserving delimiters
332
- tokens = re.split(r'(\b\w+\b)', stmt)
333
- for i, tok in enumerate(tokens):
334
- if not tok or not tok[0].isalpha():
335
- continue
336
- if tok in protected or tok.upper() in protected or tok.lower() in protected:
337
- continue
338
- if tok in merged:
339
- # Don't expand record bases (dicts) — leave p in p.x intact so
340
- # the expression evaluator can resolve the field.
341
- if isinstance(merged[tok], dict):
368
+
369
+ def _sub_segment(seg: str) -> str:
370
+ # Tokenize: split on word boundaries, preserving delimiters
371
+ tokens = re.split(r'(\b\w+\b)', seg)
372
+ for i, tok in enumerate(tokens):
373
+ if not tok or not tok[0].isalpha():
342
374
  continue
343
- tokens[i] = str(merged[tok])
344
- return ''.join(tokens)
375
+ if tok in protected or tok.upper() in protected or tok.lower() in protected:
376
+ continue
377
+ if tok in merged:
378
+ # Don't expand record bases (dicts) — leave p in p.x intact
379
+ # so the expression evaluator can resolve the field.
380
+ if isinstance(merged[tok], dict):
381
+ continue
382
+ tokens[i] = str(merged[tok])
383
+ return ''.join(tokens)
384
+
385
+ # Substitute only outside quoted string literals: a variable named in a
386
+ # PRINT label (PRINT "S ="; S) must survive verbatim inside the quotes.
387
+ out: list[str] = []
388
+ i, n = 0, len(stmt)
389
+ while i < n:
390
+ ch = stmt[i]
391
+ if ch in ('"', "'"):
392
+ j = i + 1
393
+ while j < n and stmt[j] != ch:
394
+ j += 1
395
+ out.append(stmt[i:j + 1]) # quoted span, verbatim
396
+ i = j + 1
397
+ else:
398
+ j = i
399
+ while j < n and stmt[j] not in ('"', "'"):
400
+ j += 1
401
+ out.append(_sub_segment(stmt[i:j])) # unquoted span
402
+ i = j
403
+ return ''.join(out)
345
404
 
346
405
  def _expand_statement(self, stmt, _call_stack: set[str] | None = None):
347
406
  """Expand subroutines. Returns list of gate strings.
@@ -636,18 +695,24 @@ class ExecutorMixin:
636
695
 
637
696
  Uses the same _exec_line pipeline as cmd_run for consistency.
638
697
  """
639
- # In LOCC mode, handle @register prefix via the numpy engine
640
- if self.locc_mode and self.locc:
641
- m = RE_AT_REG_LINE.match(line)
642
- if m:
698
+ # In LOCC mode, handle @register prefix via the numpy engine. Split
699
+ # colon-compound @REG lines so each gate is applied (a single @REG line
700
+ # is just the one-part case); a bare @REG line keeps prior behavior.
701
+ if self.locc_mode and self.locc and line.strip().startswith('@'):
702
+ parts = self._split_colon_stmts(line) if ':' in line else [line.strip()]
703
+ for part in parts:
704
+ m = RE_AT_REG_LINE.match(part)
705
+ if not m:
706
+ self.io.writeln(f"?BAD @register statement: {part}")
707
+ continue
643
708
  reg = m.group(1).upper()
644
709
  gate_stmt = m.group(2).strip()
645
710
  if reg not in self.locc.names:
646
711
  self.io.writeln(f"?UNKNOWN REGISTER: {reg} (have {', '.join(self.locc.names)})")
647
712
  return
648
713
  self._locc_apply_gate(reg, gate_stmt)
649
- self._locc_state()
650
- return
714
+ self._locc_state()
715
+ return
651
716
  if line.strip().startswith('@'):
652
717
  self.io.writeln("?@register syntax requires LOCC mode (try: LOCC <n1> <n2>)")
653
718
  return
@@ -657,6 +722,12 @@ class ExecutorMixin:
657
722
  imm_ctx = ExecContext(sorted_lines=[0], ip=0,
658
723
  run_vars=dict(self.variables), qc=qc)
659
724
  self._exec_line(line, ctx=imm_ctx)
725
+ # A classical statement typed at the prompt (PRINT, MEASURE, an IF whose
726
+ # clause printed, ...) adds no circuit operations and has already emitted
727
+ # its own output via _exec_line. Simulating an empty circuit just to print
728
+ # an unchanged |psi> would be spurious, so skip the statevector dump.
729
+ if qc.size() == 0:
730
+ return
660
731
  qc.save_statevector()
661
732
  backend = self._make_backend('statevector')
662
733
  result = backend.run(transpile(qc, backend)).result()
@@ -195,8 +195,6 @@ class FileIOMixin:
195
195
  DEFs in the imported file are prefixed with the module name.
196
196
  E.g., IMPORT "math.qb" makes DEF ROT available as MATH.ROT.
197
197
  """
198
- from qubasic_core.engine import RE_IMPORT
199
- m = RE_IMPORT.match(f"IMPORT {rest}") if not rest.startswith('IMPORT') else RE_IMPORT.match(rest)
200
198
  path = rest.strip().strip('"').strip("'")
201
199
  if not path:
202
200
  self.io.writeln('?USAGE: IMPORT "filename"')
@@ -180,7 +180,13 @@ class LOCCEngine:
180
180
  self._renormalize(reg)
181
181
 
182
182
  def apply(self, reg: str, gate_name: str, params: tuple[float, ...], qubits: list[int]) -> None:
183
- """Apply a gate to a specific register, then apply noise if configured."""
183
+ """Apply a gate to a register, then apply noise if configured.
184
+
185
+ Qubit indices are register-LOCAL (0..size-1); the register offset into
186
+ the joint statevector is added internally. Callers that index the raw
187
+ statevector (e.g. for fidelity/inspection) must convert with
188
+ ``offsets[reg] + local`` themselves.
189
+ """
184
190
  self._check_qubits(reg, qubits)
185
191
  matrix = _np_gate_matrix(gate_name, tuple(params))
186
192
  if self.joint:
@@ -194,7 +200,10 @@ class LOCCEngine:
194
200
  self._contiguate(reg)
195
201
 
196
202
  def share(self, reg1: str, q1: int, reg2: str, q2: int) -> None:
197
- """Create Bell pair |Phi+> between reg1[q1] and reg2[q2]. JOINT only."""
203
+ """Create Bell pair |Phi+> between reg1[q1] and reg2[q2]. JOINT only.
204
+
205
+ ``q1``/``q2`` are register-LOCAL indices; offsets are added internally.
206
+ """
198
207
  if not self.joint:
199
208
  raise RuntimeError("SHARE requires LOCC JOINT mode")
200
209
  self._check_qubits(reg1, [q1])
@@ -210,7 +219,10 @@ class LOCCEngine:
210
219
  self._apply_noise(reg2, [q2])
211
220
 
212
221
  def send(self, reg: str, qubit: int) -> int:
213
- """Measure a qubit (Born rule) and return the classical outcome."""
222
+ """Measure a qubit (Born rule) and return the classical outcome.
223
+
224
+ ``qubit`` is register-LOCAL (0..size-1); the offset is added internally.
225
+ """
214
226
  self._check_qubits(reg, [qubit])
215
227
  if self.joint:
216
228
  actual = qubit + self.offsets[self._idx(reg)]
@@ -266,7 +278,10 @@ class LOCCEngine:
266
278
  return per_reg, {}
267
279
 
268
280
  def apply_matrix(self, reg: str, matrix: np.ndarray, qubits: list[int]) -> None:
269
- """Apply a raw unitary matrix to qubits in a register."""
281
+ """Apply a raw unitary matrix to qubits in a register.
282
+
283
+ ``qubits`` are register-LOCAL indices; offsets are added internally.
284
+ """
270
285
  self._check_qubits(reg, qubits)
271
286
  if self.joint:
272
287
  idx = self._idx(reg)
@@ -33,8 +33,7 @@ class LOCCExecutionMixin:
33
33
  if not sorted_lines:
34
34
  self.io.writeln("NOTHING TO RUN")
35
35
  return
36
- has_measure = any(self.program[l].strip().upper() == 'MEASURE'
37
- for l in sorted_lines)
36
+ has_measure = self._program_has_measure(sorted_lines)
38
37
  has_send = any(re.search(r'\bSEND\b', self.program[l], re.IGNORECASE)
39
38
  for l in sorted_lines)
40
39
  if has_send:
@@ -103,7 +103,10 @@ class MemoryMixin:
103
103
  qubit, field = off // QUBIT_BLOCK, off % QUBIT_BLOCK
104
104
  if self.last_sv is None or qubit >= self.num_qubits:
105
105
  return 0.0
106
- sv = np.ascontiguousarray(self.last_sv).ravel().reshape([2] * self.num_qubits)
106
+ sv_flat = np.ascontiguousarray(self.last_sv).ravel()
107
+ if sv_flat.size != 2 ** self.num_qubits:
108
+ return 0.0 # stale statevector from a different qubit count
109
+ sv = sv_flat.reshape([2] * self.num_qubits)
107
110
  ax = self.num_qubits - 1 - qubit
108
111
  t = np.moveaxis(sv, ax, 0)
109
112
  t0, t1 = t[0].flatten(), t[1].flatten()
@@ -155,7 +158,10 @@ class MemoryMixin:
155
158
  self._zero_page[a] = v
156
159
  return
157
160
  if a == 0xD000:
158
- self.num_qubits = max(1, min(32, int(v)))
161
+ new_n = max(1, min(32, int(v)))
162
+ if new_n != self.num_qubits:
163
+ self._invalidate_run_state()
164
+ self.num_qubits = new_n
159
165
  elif a == 0xD001:
160
166
  self.shots = max(1, int(v))
161
167
  elif a == 0xD002:
@@ -554,6 +554,14 @@ def parse_stmt(raw: str) -> Stmt:
554
554
 
555
555
  # ── @REG lines ────────────────────────────────────────────────
556
556
  if text.startswith('@'):
557
+ # A colon-compound @REG line (e.g. "@A H 0 : @A CX 0,1" or
558
+ # "@A H 0 : CX 0,1") must split into per-statement parts with
559
+ # @register inheritance; otherwise the whole tail is captured as one
560
+ # AtRegStmt.inner and the LOCC executor mis-tokenizes it.
561
+ if ':' in text:
562
+ parts = _split_colon_stmts(text)
563
+ if len(parts) > 1:
564
+ return CompoundStmt(raw=raw, parts=tuple(parts))
557
565
  m = RE_AT_REG_LINE.match(text)
558
566
  if m:
559
567
  return AtRegStmt(raw=raw, reg=m.group(1).upper(),
@@ -571,6 +571,8 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
571
571
  from qubasic_core.errors import QBasicRangeError
572
572
  raise QBasicRangeError(f"RANGE: 1-{MAX_QUBITS}")
573
573
 
574
+ if n != self.num_qubits:
575
+ self._invalidate_run_state()
574
576
  self.num_qubits = n
575
577
  self.registers.clear()
576
578
  est = _estimate_gb(n)
@@ -926,7 +928,18 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
926
928
 
927
929
  def cmd_let(self, rest: str) -> None:
928
930
  """LET <var> = <expr> — assign a computed value to a variable.
929
- Supports record fields, e.g. LET p.x = 3.14 (see TYPE)."""
931
+ Supports record fields (LET p.x = 3.14) and array elements
932
+ (LET a(0) = PI, LET m(i, j) = x), matching the in-program LET."""
933
+ from qubasic_core.patterns import RE_LET_ARRAY
934
+ am = RE_LET_ARRAY.match(f"LET {rest}")
935
+ if am:
936
+ from qubasic_core.statements import LetArrayStmt
937
+ name, idx_expr, val_expr = am.group(1), am.group(2), am.group(3)
938
+ parsed = LetArrayStmt(raw=f"LET {rest}", name=name,
939
+ index_expr=idx_expr, value_expr=val_expr)
940
+ self._cf_let_array(f"LET {rest}", self.variables, parsed)
941
+ self.io.writeln(f"{name}({idx_expr.strip()}) = {self.eval_expr(val_expr)}")
942
+ return
930
943
  m = re.match(r'(\w+(?:\.\w+)?)\s*=\s*(.*)', rest)
931
944
  if not m:
932
945
  self.io.writeln("?USAGE: LET <var> = <expr>")
@@ -1980,8 +1993,13 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1980
1993
  full_pauli[self.num_qubits - 1 - qubits[i]] = p
1981
1994
  op = SparsePauliOp(''.join(full_pauli))
1982
1995
  qc.save_expectation_value(op, list(range(self.num_qubits)), label=f'exp_{var}')
1983
- run_vars[var] = 0 # actual value available after simulation
1984
- self.variables[var] = 0
1996
+ # Placeholder until the run fills the real value via
1997
+ # _extract_save_results. Preserve any prior value instead of
1998
+ # zeroing it, so a re-run that re-includes this SAVE line leaves
1999
+ # earlier results readable to LET/PRINT during the build pass.
2000
+ _prev = self.variables.get(var, 0)
2001
+ run_vars[var] = _prev
2002
+ self.variables[var] = _prev
1985
2003
  except Exception as e:
1986
2004
  self.io.writeln(f"?SAVE_EXPECT ERROR: {e}")
1987
2005
  return True
@@ -1996,8 +2014,9 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1996
2014
  var = m.group(2)
1997
2015
  try:
1998
2016
  qc.save_probabilities(qubits, label=f'prob_{var}')
1999
- run_vars[var] = 0
2000
- self.variables[var] = 0
2017
+ _prev = self.variables.get(var, 0) # preserve prior value (see SAVE_EXPECT)
2018
+ run_vars[var] = _prev
2019
+ self.variables[var] = _prev
2001
2020
  except Exception as e:
2002
2021
  self.io.writeln(f"?SAVE_PROBS ERROR: {e}")
2003
2022
  return True
@@ -2012,8 +2031,9 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
2012
2031
  var = m.group(2)
2013
2032
  try:
2014
2033
  qc.save_amplitudes(indices, label=f'amp_{var}')
2015
- run_vars[var] = 0
2016
- self.variables[var] = 0
2034
+ _prev = self.variables.get(var, 0) # preserve prior value (see SAVE_EXPECT)
2035
+ run_vars[var] = _prev
2036
+ self.variables[var] = _prev
2017
2037
  except Exception as e:
2018
2038
  self.io.writeln(f"?SAVE_AMPS ERROR: {e}")
2019
2039
  return True
@@ -359,6 +359,42 @@ class TestMemoryMap(unittest.TestCase):
359
359
  self.assertEqual(ntype, 2)
360
360
  self.assertAlmostEqual(nparam, 0.1)
361
361
 
362
+ def test_qubit_count_change_invalidates_stale_state(self):
363
+ """Changing the qubit count after a run must not leave a stale last_sv
364
+ that MAP/PEEK/BLOCH then reshape to the wrong size and crash."""
365
+ import numpy as np
366
+
367
+ # Run a 1-qubit circuit so last_sv has size 2.
368
+ t = QBasicTerminal()
369
+ t.num_qubits = 1
370
+ t.process('10 H 0')
371
+ t.process('20 MEASURE')
372
+ capture(t.cmd_run)
373
+
374
+ # QUBITS grows the count: the size-2 statevector no longer applies.
375
+ capture(t.cmd_qubits, '2')
376
+ self.assertIsNone(t.last_sv)
377
+ self.assertIsNone(t._circuit_cache_key)
378
+ self.assertEqual(t._peek(0x0100), 0.0) # would have crashed on reshape
379
+ capture(t.cmd_map) # MAP walks the qubit block
380
+ capture(t.cmd_bloch, '0') # BLOCH reshapes too
381
+
382
+ # The $D000 POKE path invalidates the same way.
383
+ t2 = QBasicTerminal()
384
+ t2.num_qubits = 1
385
+ t2.process('10 H 0')
386
+ t2.process('20 MEASURE')
387
+ capture(t2.cmd_run)
388
+ t2._poke(0xD000, 3)
389
+ self.assertIsNone(t2.last_sv)
390
+ self.assertEqual(t2._peek(0x0100), 0.0)
391
+
392
+ # Defensive guard: even a hand-set mismatched last_sv reads as 0, not a crash.
393
+ t3 = QBasicTerminal()
394
+ t3.num_qubits = 2
395
+ t3.last_sv = np.array([1.0, 0.0], dtype=complex) # size 2, but 2 qubits
396
+ self.assertEqual(t3._peek(0x0100), 0.0)
397
+
362
398
  def test_sys(self):
363
399
  """SYS builtin BELL, SYS unmapped, SYS in program, SYS INSTALL."""
364
400
  # SYS 0xE000 BELL demo
@@ -1432,6 +1432,92 @@ class TestAdditionalCoverage(unittest.TestCase):
1432
1432
  self.assertEqual(t4.last_counts.get('1', 0), 100)
1433
1433
 
1434
1434
 
1435
+ # ---------------------------------------------------------------------------
1436
+ # TestBugFixes063 — regressions fixed in 0.6.3
1437
+ # ---------------------------------------------------------------------------
1438
+ class TestBugFixes063(unittest.TestCase):
1439
+ """Regression tests for the 0.6.3 fixes."""
1440
+
1441
+ def _ghz3(self):
1442
+ v = np.zeros(8, dtype=complex)
1443
+ v[0] = v[7] = 1 / np.sqrt(2)
1444
+ return v
1445
+
1446
+ def test_locc_colon_compound_at_register(self):
1447
+ """LOCC: colon-compound @REG lines apply every gate (explicit and inherited)."""
1448
+ for line in ('@A H 0 : @A CX 0,1 : @A CX 0,2', # explicit @A per clause
1449
+ '@A H 0 : CX 0,1 : CX 0,2'): # @A inherited
1450
+ t = QBasicTerminal()
1451
+ t.locc = LOCCEngine([3], joint=True)
1452
+ t.locc_mode = True
1453
+ t.shots = 1
1454
+ t.process(f'10 {line}', track_undo=False)
1455
+ t.process('20 MEASURE', track_undo=False)
1456
+ capture(t.cmd_run)
1457
+ sv = np.ascontiguousarray(t.locc.sv).ravel()
1458
+ fid = abs(np.vdot(self._ghz3(), sv)) ** 2
1459
+ self.assertGreater(fid, 0.999, f"GHZ not built by: {line}")
1460
+
1461
+ def test_save_expect_preserved_across_rerun(self):
1462
+ """SAVE_EXPECT keeps prior values on re-run instead of zeroing them."""
1463
+ t = QBasicTerminal()
1464
+ t.num_qubits = 2
1465
+ t.shots = 64
1466
+ t.process('10 H 0', track_undo=False)
1467
+ t.process('20 CX 0,1', track_undo=False)
1468
+ t.process('30 SAVE_EXPECT ZZ 0,1 -> zz', track_undo=False)
1469
+ t.process('40 SAVE_EXPECT XX 0,1 -> xx', track_undo=False)
1470
+ capture(t.cmd_run)
1471
+ self.assertAlmostEqual(t.variables['zz'], 1.0, places=6)
1472
+ self.assertAlmostEqual(t.variables['xx'], 1.0, places=6)
1473
+ # A second run that still contains the SAVE lines must not zero zz/xx
1474
+ # before the LET on line 50 reads them.
1475
+ t.process('50 LET chsh = SQRT2 * (zz + xx)', track_undo=False)
1476
+ capture(t.cmd_run)
1477
+ self.assertAlmostEqual(t.variables['chsh'], 2 * math.sqrt(2), places=6)
1478
+
1479
+ def test_print_multi_item_separators(self):
1480
+ """PRINT concatenates ';' items and tab-aligns ',' items."""
1481
+ t = QBasicTerminal()
1482
+ t.num_qubits = 1
1483
+ t.variables['S'] = 2.8284271247
1484
+ t.process('10 PRINT "S ="; S', track_undo=False)
1485
+ _, out = capture(t.cmd_run)
1486
+ self.assertIn('S =2.8284', out)
1487
+
1488
+ t2 = QBasicTerminal()
1489
+ t2.num_qubits = 1
1490
+ t2.process('10 PRINT "a", "b"', track_undo=False)
1491
+ _, out2 = capture(t2.cmd_run)
1492
+ line = next(l for l in out2.splitlines() if 'a' in l and 'b' in l)
1493
+ self.assertGreaterEqual(line.index('b') - line.index('a'), 14)
1494
+
1495
+ def test_print_does_not_substitute_inside_quotes(self):
1496
+ """A variable name inside a quoted PRINT literal is emitted verbatim."""
1497
+ t = QBasicTerminal()
1498
+ t.num_qubits = 1
1499
+ t.variables['S'] = 99.0
1500
+ t.process('10 PRINT "value of S here"', track_undo=False)
1501
+ _, out = capture(t.cmd_run)
1502
+ self.assertIn('value of S here', out)
1503
+ self.assertNotIn('99', out)
1504
+
1505
+ def test_print_preserves_commas_in_quotes_and_calls(self):
1506
+ """Top-level split skips commas inside quotes and inside call parens."""
1507
+ t = QBasicTerminal()
1508
+ t.num_qubits = 1
1509
+ t.process('10 PRINT "x,y,z"', track_undo=False)
1510
+ _, out = capture(t.cmd_run)
1511
+ self.assertIn('x,y,z', out)
1512
+
1513
+ t2 = QBasicTerminal()
1514
+ t2.num_qubits = 1
1515
+ t2.process('10 LET name$ = "hello"', track_undo=False)
1516
+ t2.process('20 PRINT LEFT$(name$, 3)', track_undo=False)
1517
+ _, out2 = capture(t2.cmd_run)
1518
+ self.assertIn('hel', out2)
1519
+
1520
+
1435
1521
  if __name__ == '__main__':
1436
1522
  if hasattr(sys.stdout, 'reconfigure'):
1437
1523
  sys.stdout.reconfigure(encoding='utf-8')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes