oaknut-basic 12.10.1__tar.gz → 12.12.0__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 (41) hide show
  1. {oaknut_basic-12.10.1/src/oaknut_basic.egg-info → oaknut_basic-12.12.0}/PKG-INFO +1 -1
  2. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/__init__.py +9 -1
  3. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/cli.py +70 -2
  4. oaknut_basic-12.12.0/src/oaknut/basic/detect.py +212 -0
  5. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/detokeniser.py +14 -5
  6. oaknut_basic-12.12.0/src/oaknut/basic/dialect.py +164 -0
  7. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/scanner.py +27 -9
  8. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0/src/oaknut_basic.egg-info}/PKG-INFO +1 -1
  9. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/SOURCES.txt +4 -0
  10. oaknut_basic-12.12.0/tests/test_basic_v.py +195 -0
  11. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_cli.py +79 -0
  12. oaknut_basic-12.12.0/tests/test_detect.py +152 -0
  13. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/LICENSE +0 -0
  14. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/README.md +0 -0
  15. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/pyproject.toml +0 -0
  16. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/setup.cfg +0 -0
  17. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/datafile.py +0 -0
  18. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/exceptions.py +0 -0
  19. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/float5.py +0 -0
  20. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/linenumber.py +0 -0
  21. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/numbering.py +0 -0
  22. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/tokeniser.py +0 -0
  23. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut/basic/tokens.py +0 -0
  24. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/dependency_links.txt +0 -0
  25. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/entry_points.txt +0 -0
  26. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/requires.txt +0 -0
  27. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/src/oaknut_basic.egg-info/top_level.txt +0 -0
  28. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_basic.py +0 -0
  29. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_crunch_rules.py +0 -0
  30. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_data_cli.py +0 -0
  31. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_datafile.py +0 -0
  32. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_detokeniser.py +0 -0
  33. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_float5.py +0 -0
  34. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_keyword_coverage.py +0 -0
  35. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_linenumber.py +0 -0
  36. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_numbering.py +0 -0
  37. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_rom_golden.py +0 -0
  38. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_rom_golden_detokenise.py +0 -0
  39. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_scanner.py +0 -0
  40. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_tokeniser.py +0 -0
  41. {oaknut_basic-12.10.1 → oaknut_basic-12.12.0}/tests/test_tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oaknut-basic
3
- Version: 12.10.1
3
+ Version: 12.12.0
4
4
  Summary: BBC BASIC tools: program tokeniser/de-tokeniser and PRINT#/INPUT# data-file reader/writer
5
5
  Author-email: Robert Smallshire <robert@smallshire.org.uk>
6
6
  License-Expression: MIT
@@ -30,7 +30,9 @@ from oaknut.basic.datafile import (
30
30
  BbcBasicDataReader,
31
31
  BbcBasicDataWriter,
32
32
  )
33
+ from oaknut.basic.detect import Detection, Verdict, detect
33
34
  from oaknut.basic.detokeniser import detokenise, detokenise_body
35
+ from oaknut.basic.dialect import BASIC_II, BASIC_V, Dialect
34
36
  from oaknut.basic.exceptions import (
35
37
  AlreadyNumberedError,
36
38
  BASICError,
@@ -79,7 +81,7 @@ from oaknut.basic.tokens import (
79
81
  TOKEN_TO_KEYWORD,
80
82
  )
81
83
 
82
- __version__ = "12.10.1"
84
+ __version__ = "12.12.0"
83
85
 
84
86
  # Canonical load addresses for BBC BASIC programs on each host.
85
87
  # Programs saved by *SAVE on a real machine use these by default.
@@ -87,6 +89,8 @@ BBC_BASIC_LOAD_ADDRESS = 0x1900
87
89
  ELECTRON_BASIC_LOAD_ADDRESS = 0x0E00
88
90
 
89
91
  __all__ = [
92
+ "BASIC_II",
93
+ "BASIC_V",
90
94
  "BBC_BASIC_LOAD_ADDRESS",
91
95
  "DEFAULT_LINE_NUMBER",
92
96
  "DEFAULT_LINE_STEP",
@@ -109,7 +113,9 @@ __all__ = [
109
113
  "BbcBasicDataWriter",
110
114
  "DataFileError",
111
115
  "DataFileTypeMismatchError",
116
+ "Detection",
112
117
  "DetokeniseError",
118
+ "Dialect",
113
119
  "Float5RangeError",
114
120
  "IntegerRangeError",
115
121
  "InvalidLineLengthError",
@@ -126,7 +132,9 @@ __all__ = [
126
132
  "TruncatedRecordError",
127
133
  "UnknownTagError",
128
134
  "UnnumberedLineError",
135
+ "Verdict",
129
136
  "decode_line_number",
137
+ "detect",
130
138
  "detokenise",
131
139
  "detokenise_body",
132
140
  "number_lines",
@@ -260,7 +260,16 @@ def tokenise(
260
260
  help='Text encoding for the OUTPUT source. Use "acorn" for the BBC '
261
261
  "character set with CR line endings (e.g. writing back to a disc image).",
262
262
  )
263
- def detokenise(input_stream, output_stream, encoding: str) -> None:
263
+ @click.option(
264
+ "--dialect",
265
+ type=click.Choice(["ii", "2", "v", "5"]),
266
+ default="ii",
267
+ show_default=True,
268
+ help='BBC BASIC dialect, as a Roman or Arabic numeral. Use "v" (or "5") for '
269
+ "Archimedes / RISC OS programs, so the &C6/&C7/&C8 escape tokens "
270
+ "(CASE, SYS, ORIGIN, ...) decode correctly.",
271
+ )
272
+ def detokenise(input_stream, output_stream, encoding: str, dialect: str) -> None:
264
273
  """De-tokenise a stored BBC BASIC program into source text.
265
274
 
266
275
  Reads a tokenised program from INPUT and writes numbered source text
@@ -273,13 +282,72 @@ def detokenise(input_stream, output_stream, encoding: str) -> None:
273
282
  OUTPUT is written in --encoding (``utf-8`` with host-native ``LF`` line
274
283
  endings by default, for a host text file; pass ``acorn`` for the BBC
275
284
  character set with ``CR`` endings, e.g. writing back to a disc image).
285
+
286
+ Pass ``--dialect v`` (or ``--dialect 5``) for Archimedes / RISC OS
287
+ programs (BBC BASIC V), whose extended keywords use the &C6/&C7/&C8
288
+ two-byte escape tokens. The Roman (``ii``/``v``) and Arabic
289
+ (``2``/``5``) numerals are interchangeable.
276
290
  """
291
+ from oaknut.basic import BASIC_II, BASIC_V
277
292
  from oaknut.basic import detokenise as detokenise_program
278
293
 
279
- listing = detokenise_program(input_stream.read())
294
+ selected_dialect = BASIC_V if dialect in ("v", "5") else BASIC_II
295
+ listing = detokenise_program(input_stream.read(), dialect=selected_dialect)
280
296
  output_stream.write(_listing_to_bytes(listing, encoding))
281
297
 
282
298
 
299
+ @cli.command()
300
+ @click.argument(
301
+ "inputs",
302
+ metavar="[FILE]...",
303
+ type=click.File("rb"),
304
+ nargs=-1,
305
+ )
306
+ @click.option(
307
+ "--quiet",
308
+ "-q",
309
+ is_flag=True,
310
+ help="Suppress per-file output; report the verdict through the exit code only.",
311
+ )
312
+ @click.pass_context
313
+ def detect(ctx: click.Context, inputs, quiet: bool) -> None:
314
+ """Identify whether each FILE is a tokenised BBC BASIC program.
315
+
316
+ Classifies each FILE by its byte structure alone — the length-driven
317
+ line walk the BBC BASIC ROM performs to ``LIST`` a program — without
318
+ de-tokenising, so unlabelled files harvested from disc images can be
319
+ filtered. With no FILE, or ``-``, reads a single blob from standard
320
+ input ::
321
+
322
+ oaknut-basic detect *
323
+ disc get game.ssd MENU - | oaknut-basic detect
324
+
325
+ Each line reports a ``BASIC`` flag (blank when not), the verdict, the
326
+ path and the reason; non-fatal observations follow as indented notes.
327
+ The exit code is non-zero if any input is not a BASIC program, so it
328
+ composes as a shell filter. The verdict is one of ``basic`` (a clean
329
+ program), ``basic+`` (a program with data appended), ``maybe`` (begins
330
+ as BASIC, then the structure breaks) or ``not-basic``.
331
+ """
332
+ from oaknut.basic import detect as detect_blob
333
+
334
+ streams = inputs if inputs else (click.get_binary_stream("stdin"),)
335
+ all_basic = True
336
+ for stream in streams:
337
+ result = detect_blob(stream.read())
338
+ all_basic = all_basic and result.is_basic
339
+ if quiet:
340
+ continue
341
+ name = getattr(stream, "name", "<stdin>")
342
+ flag = "BASIC" if result.is_basic else " "
343
+ click.echo(f"{flag} {result.verdict.value:9} {name} -- {result.reason}")
344
+ for note in result.notes:
345
+ click.echo(f" note: {note}")
346
+
347
+ if not all_basic:
348
+ ctx.exit(1)
349
+
350
+
283
351
  @cli.group()
284
352
  def data() -> None:
285
353
  """Read and write BBC BASIC data files (PRINT#/INPUT#/BPUT#/BGET#).
@@ -0,0 +1,212 @@
1
+ """Identify tokenised BBC BASIC programs by structure alone.
2
+
3
+ Files harvested from Acorn disc images (DFS ``.ssd``, ADFS ``.adl``)
4
+ carry no filetype or extension, so a blob's nature has to be inferred
5
+ from its bytes. A stored BBC BASIC program — BASIC I, II, IV and V all
6
+ share the same line framing — is a run of lines::
7
+
8
+ &0D <line-hi> <line-lo> <len> <len-4 tokenised body bytes>
9
+
10
+ repeated, then a terminator: ``&0D`` followed by a byte with its top bit
11
+ set. BASIC writes ``&0D &FF``, but the ROM only tests the top bit, and
12
+ programs with data appended sometimes tamper with the second byte, so
13
+ ``&0D &80``-``&FF`` all terminate — :func:`detect` matches the ROM, not
14
+ the literal ``&FF``, and merely notes a non-``&FF`` terminator.
15
+
16
+ The walk is length-driven: each line's own ``<len>`` byte says where the
17
+ next ``&0D`` must be, so tokenised body bytes that happen to look like
18
+ ``&0D`` never trip the scan. It is exactly what the ROM does to ``LIST``
19
+ a program, which makes it about as faithful a classifier as is possible
20
+ without a full de-tokenise. Because the framing is identical across all
21
+ BASIC versions, detection is purely structural and needs no
22
+ :class:`~oaknut.basic.Dialect` — it sits well below the token table.
23
+
24
+ Unlike :func:`~oaknut.basic.scan_program`, which raises
25
+ :class:`~oaknut.basic.DetokeniseError` on malformed input, :func:`detect`
26
+ is **total**: it classifies clean programs, program-plus-data, truncated
27
+ fragments and arbitrary garbage into a :class:`Verdict` with evidence,
28
+ and never raises. Classification is deliberately conservative and
29
+ reason-bearing so a corpus filter can explain every accept and reject.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import enum
35
+ from dataclasses import dataclass, field
36
+
37
+ from oaknut.basic.tokens import HEADER_LENGTH
38
+
39
+ _CR = 0x0D
40
+
41
+ # A stored line number is 0-32767 (see oaknut.basic.linenumber.MAX_LINE_NUMBER),
42
+ # so its high byte is always <= &7F — top bit clear. The end-of-program
43
+ # terminator's second byte has the top bit set (&FF, or a tampered &80-&FE).
44
+ # Testing the top bit therefore tells a terminator from a line-number high
45
+ # byte, exactly as the ROM's LIST loop does.
46
+ _TERMINATOR_TOP_BIT = 0x80
47
+
48
+
49
+ class Verdict(enum.Enum):
50
+ """How confident :func:`detect` is that a blob is tokenised BBC BASIC."""
51
+
52
+ #: A clean walk all the way to a proper terminator, no trailing bytes.
53
+ BASIC = "basic"
54
+ #: A valid program followed by extra bytes (data appended after the end).
55
+ BASIC_TRAILING = "basic+"
56
+ #: Begins as one or more well-formed lines, then the structure breaks.
57
+ MAYBE = "maybe"
58
+ #: Does not begin as a tokenised line at all.
59
+ NOT_BASIC = "not-basic"
60
+
61
+ @property
62
+ def is_basic(self) -> bool:
63
+ """True for the verdicts a corpus filter would extract as BASIC."""
64
+ return self in (Verdict.BASIC, Verdict.BASIC_TRAILING)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Detection:
69
+ """The outcome of inspecting one blob, with the evidence behind it.
70
+
71
+ Attributes:
72
+ verdict: The :class:`Verdict` reached.
73
+ reason: A human-readable explanation of the decision.
74
+ line_count: Well-formed lines walked before the terminator or break.
75
+ program_length: Bytes up to and including the terminator (0 if none
76
+ was reached).
77
+ trailing_length: Bytes after the terminator (non-zero only for
78
+ :attr:`Verdict.BASIC_TRAILING`).
79
+ first_line: The first line's number, or ``None`` if no line parsed.
80
+ last_line: The last line's number, or ``None`` if no line parsed.
81
+ ascending: Whether the line numbers were strictly ascending.
82
+ notes: Non-fatal observations (e.g. a non-``&FF`` terminator).
83
+ """
84
+
85
+ verdict: Verdict
86
+ reason: str
87
+ line_count: int = 0
88
+ program_length: int = 0
89
+ trailing_length: int = 0
90
+ first_line: int | None = None
91
+ last_line: int | None = None
92
+ ascending: bool = True
93
+ notes: tuple[str, ...] = field(default_factory=tuple)
94
+
95
+ @property
96
+ def is_basic(self) -> bool:
97
+ """True when the verdict is :attr:`Verdict.BASIC` or ``BASIC_TRAILING``."""
98
+ return self.verdict.is_basic
99
+
100
+
101
+ def detect(data: bytes | bytearray) -> Detection:
102
+ """Classify *data* as tokenised BBC BASIC (or not) by walking its lines.
103
+
104
+ Args:
105
+ data: The raw bytes to classify.
106
+
107
+ Returns:
108
+ A :class:`Detection` carrying the :class:`Verdict` and the
109
+ evidence gathered during the walk. Always returns; never raises.
110
+ """
111
+ n = len(data)
112
+ if n < 2:
113
+ return Detection(Verdict.NOT_BASIC, "too short to be a BASIC line")
114
+ if data[0] != _CR:
115
+ return Detection(
116
+ Verdict.NOT_BASIC,
117
+ f"first byte is &{data[0]:02X}, not a &0D line marker",
118
+ )
119
+
120
+ pos = 0
121
+ line_count = 0
122
+ first_line: int | None = None
123
+ last_line: int | None = None
124
+ prev_line: int | None = None
125
+ ascending = True
126
+ notes: list[str] = []
127
+
128
+ def broke(reason: str) -> Detection:
129
+ """A broken walk is MAYBE if it parsed >=1 clean line, else NOT_BASIC.
130
+
131
+ One or more well-formed lines before the break means the blob
132
+ really does begin as a tokenised program (truncated, or BASIC
133
+ followed by data without a proper terminator); zero clean lines
134
+ means the leading ``&0D`` was a coincidence.
135
+ """
136
+ return Detection(
137
+ Verdict.MAYBE if line_count > 0 else Verdict.NOT_BASIC,
138
+ reason,
139
+ line_count=line_count,
140
+ first_line=first_line,
141
+ last_line=last_line,
142
+ ascending=ascending,
143
+ notes=tuple(notes),
144
+ )
145
+
146
+ while True:
147
+ # Every iteration must land on a line marker.
148
+ if pos >= n or data[pos] != _CR:
149
+ found = f"&{data[pos]:02X}" if pos < n else "end of data"
150
+ return broke(f"expected &0D at offset {pos}, found {found}")
151
+
152
+ # The byte after &0D is either a terminator (top bit set) or a
153
+ # line-number high byte.
154
+ if pos + 1 >= n:
155
+ return broke(f"truncated after &0D at offset {pos}")
156
+
157
+ marker = data[pos + 1]
158
+ if marker & _TERMINATOR_TOP_BIT:
159
+ # A terminator before any line parsed means the leading &0D was
160
+ # a coincidence (e.g. a View document opening &0D &80), not an
161
+ # empty program — real type-ins always have at least one line.
162
+ if line_count == 0:
163
+ return Detection(
164
+ Verdict.NOT_BASIC,
165
+ f"&0D &{marker:02X} at offset 0 — terminator with no lines, "
166
+ "not a program",
167
+ )
168
+ program_length = pos + 2
169
+ trailing = n - program_length
170
+ if marker != 0xFF:
171
+ notes.append(
172
+ f"terminator second byte &{marker:02X} (not &FF, top bit "
173
+ "only) — data may follow"
174
+ )
175
+ verdict = Verdict.BASIC if trailing == 0 else Verdict.BASIC_TRAILING
176
+ reason = f"walked {line_count} line(s) to a terminator" + (
177
+ f", {trailing} trailing byte(s)" if trailing else ""
178
+ )
179
+ return Detection(
180
+ verdict=verdict,
181
+ reason=reason,
182
+ line_count=line_count,
183
+ program_length=program_length,
184
+ trailing_length=trailing,
185
+ first_line=first_line,
186
+ last_line=last_line,
187
+ ascending=ascending,
188
+ notes=tuple(notes),
189
+ )
190
+
191
+ # A real line: need the full 4-byte header.
192
+ if pos + HEADER_LENGTH > n:
193
+ return broke(f"truncated line header at offset {pos}")
194
+
195
+ line_no = (marker << 8) | data[pos + 2]
196
+ length = data[pos + 3]
197
+ if length < HEADER_LENGTH:
198
+ return broke(
199
+ f"line {line_no} at offset {pos} has length {length} "
200
+ f"(< {HEADER_LENGTH})"
201
+ )
202
+ if pos + length > n:
203
+ return broke(f"line {line_no} at offset {pos} runs {length} bytes past end of data")
204
+
205
+ if first_line is None:
206
+ first_line = line_no
207
+ if prev_line is not None and line_no <= prev_line:
208
+ ascending = False
209
+ prev_line = line_no
210
+ last_line = line_no
211
+ line_count += 1
212
+ pos += length
@@ -26,15 +26,21 @@ including) the next one, so ``length = 4 + len(body)``.
26
26
 
27
27
  from __future__ import annotations
28
28
 
29
+ from oaknut.basic.dialect import BASIC_II, Dialect
29
30
  from oaknut.basic.scanner import scan, scan_program
30
31
 
31
32
 
32
- def detokenise(data: bytes) -> str:
33
- """De-tokenise a BBC BASIC II program into source text.
33
+ def detokenise(data: bytes, *, dialect: Dialect = BASIC_II) -> str:
34
+ """De-tokenise a stored BBC BASIC program into source text.
34
35
 
35
36
  Args:
36
37
  data: A tokenised program, as stored on disc or in memory,
37
38
  terminated by the ``&0D &FF`` end marker.
39
+ dialect: The BBC BASIC variant whose token tables to use.
40
+ Defaults to :data:`~oaknut.basic.BASIC_II`; pass
41
+ :data:`~oaknut.basic.BASIC_V` for Archimedes / RISC OS
42
+ programs so the ``&C6``/``&C7``/``&C8`` escape tokens and the
43
+ re-purposed single-byte tokens decode correctly.
38
44
 
39
45
  Returns:
40
46
  The program as numbered source text, lines separated by ``"\\n"``
@@ -47,11 +53,11 @@ def detokenise(data: bytes) -> str:
47
53
  """
48
54
  return "".join(
49
55
  f"{record.line_number}{''.join(token.value for token in record.tokens)}\n"
50
- for record in scan_program(data)
56
+ for record in scan_program(data, dialect=dialect)
51
57
  )
52
58
 
53
59
 
54
- def detokenise_body(data: bytes) -> str:
60
+ def detokenise_body(data: bytes, *, dialect: Dialect = BASIC_II) -> str:
55
61
  """De-tokenise an unframed line body into text.
56
62
 
57
63
  Unlike :func:`detokenise`, this expects a line *body* — inline token
@@ -62,6 +68,9 @@ def detokenise_body(data: bytes) -> str:
62
68
 
63
69
  Args:
64
70
  data: A line body of inline token bytes.
71
+ dialect: The BBC BASIC variant whose token tables to use, passed
72
+ through to :func:`scan`. Defaults to
73
+ :data:`~oaknut.basic.BASIC_II`.
65
74
 
66
75
  Returns:
67
76
  The body as source text, using latin-1/code-point semantics so it
@@ -70,4 +79,4 @@ def detokenise_body(data: bytes) -> str:
70
79
  Raises:
71
80
  DetokeniseError: A ``&8D`` line-number reference is truncated.
72
81
  """
73
- return "".join(token.value for token in scan(data))
82
+ return "".join(token.value for token in scan(data, dialect=dialect))
@@ -0,0 +1,164 @@
1
+ """BBC BASIC dialects for de-tokenisation.
2
+
3
+ The :mod:`~oaknut.basic.tokens` table is **BBC BASIC II** — the BBC
4
+ Micro's 8-bit language ROM. BBC BASIC V (the Archimedes / RISC OS ARM
5
+ BASIC) keeps that table but extends it two ways, and de-tokenising a
6
+ BASIC V program with the BASIC II table corrupts every extended keyword:
7
+
8
+ - Three BASIC II command tokens — ``&C6``, ``&C7``, ``&C8`` (``AUTO``,
9
+ ``DELETE``, ``LOAD``) — become **two-byte escape prefixes**. The byte
10
+ that follows selects an extended keyword, with the second byte counting
11
+ from ``&8E`` in each table: ``&C6`` for the extended *functions*
12
+ (``SUM``, ``BEAT``), ``&C7`` for the extended *commands* (``APPEND`` …
13
+ ``RENUMBER`` … ``INSTALL``), and ``&C8`` for the extended *statements*
14
+ (``CASE``, ``CIRCLE``, ``ORIGIN``, ``SYS`` …).
15
+
16
+ - Several single-byte slots are re-purposed. ``&7F`` becomes
17
+ ``OTHERWISE``, and ``&C9``-``&CE`` — ``LIST``/``NEW``/``OLD``/
18
+ ``RENUMBER``/``SAVE`` and an unused gap in BASIC II — become the block
19
+ keywords ``WHEN``/``OF``/``ENDCASE``/``ELSE``/``ENDIF``/``ENDWHILE``
20
+ (the command spellings move into the ``&C7`` escape table).
21
+
22
+ A :class:`Dialect` bundles the single-byte token map with the escape
23
+ tables, so the scanner and de-tokeniser can target either language with
24
+ one parameter. :data:`BASIC_II` is the default everywhere — code that
25
+ does not name a dialect keeps its existing BASIC II behaviour.
26
+
27
+ The token values were cross-checked against two independent
28
+ implementations: Justin Fletcher's de-tokeniser tables (the
29
+ ``gerph/riscos-basic-detokenise`` ``c/basic`` source) and Steve Fryatt's
30
+ ``tokenize`` (``src/parse.c``), which agree byte-for-byte.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections.abc import Mapping
36
+ from dataclasses import dataclass
37
+ from types import MappingProxyType
38
+
39
+ from oaknut.basic.tokens import TOKEN_TO_KEYWORD
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Dialect:
44
+ """A BBC BASIC variant's token-to-keyword mapping.
45
+
46
+ Attributes:
47
+ name: Human-readable dialect name (e.g. ``"BBC BASIC V"``).
48
+ single_byte: Maps a single token byte to its keyword spelling.
49
+ Covers the whole one-byte keyword range, including the few
50
+ sub-``&80`` tokens such as ``&7F`` (``OTHERWISE``).
51
+ escape: Maps a two-byte escape *prefix* (``&C6``/``&C7``/``&C8``
52
+ in BASIC V) to a table from the *following* byte to its
53
+ extended keyword. Empty for dialects without escape tokens.
54
+ """
55
+
56
+ name: str
57
+ single_byte: Mapping[int, str]
58
+ escape: Mapping[int, Mapping[int, str]]
59
+
60
+
61
+ # --- BBC BASIC II -----------------------------------------------------------
62
+
63
+ #: BBC BASIC II — the BBC Micro's 8-bit language ROM. No escape tokens;
64
+ #: the single-byte map is the canonical :data:`TOKEN_TO_KEYWORD` table.
65
+ BASIC_II = Dialect(name="BBC BASIC II", single_byte=TOKEN_TO_KEYWORD, escape=MappingProxyType({}))
66
+
67
+
68
+ # --- BBC BASIC V ------------------------------------------------------------
69
+
70
+ # The extended-token tables, keyed by the byte that follows the prefix.
71
+ # Each runs from &8E upward, in ROM order.
72
+ _EXTENDED_FUNCTIONS = ("SUM", "BEAT")
73
+ _EXTENDED_COMMANDS = (
74
+ "APPEND",
75
+ "AUTO",
76
+ "CRUNCH",
77
+ "DELETE",
78
+ "EDIT",
79
+ "HELP",
80
+ "LIST",
81
+ "LOAD",
82
+ "LVAR",
83
+ "NEW",
84
+ "OLD",
85
+ "RENUMBER",
86
+ "SAVE",
87
+ "TEXTLOAD",
88
+ "TEXTSAVE",
89
+ "TWIN",
90
+ "TWINO",
91
+ "INSTALL",
92
+ )
93
+ _EXTENDED_STATEMENTS = (
94
+ "CASE",
95
+ "CIRCLE",
96
+ "FILL",
97
+ "ORIGIN",
98
+ "POINT",
99
+ "RECTANGLE",
100
+ "SWAP",
101
+ "WHILE",
102
+ "WAIT",
103
+ "MOUSE",
104
+ "QUIT",
105
+ "SYS",
106
+ "INSTALL",
107
+ "LIBRARY",
108
+ "TINT",
109
+ "ELLIPSE",
110
+ "BEATS",
111
+ "TEMPO",
112
+ "VOICES",
113
+ "VOICE",
114
+ "STEREO",
115
+ "OVERLAY",
116
+ )
117
+
118
+ _EXTENDED_TOKEN_BASE = 0x8E
119
+
120
+
121
+ def _extended_table(keywords: tuple[str, ...]) -> Mapping[int, str]:
122
+ """Build a {second byte -> keyword} table counting from ``&8E``."""
123
+ return MappingProxyType(
124
+ {_EXTENDED_TOKEN_BASE + offset: keyword for offset, keyword in enumerate(keywords)}
125
+ )
126
+
127
+
128
+ # Single-byte tokens BASIC V re-purposes from BASIC II. &7F is new;
129
+ # &C9-&CE displace the LIST/NEW/OLD/RENUMBER/SAVE command tokens (whose
130
+ # spellings move into the &C7 escape table) and the unused &CE gap.
131
+ _BASIC_V_SINGLE_BYTE_OVERRIDES = {
132
+ 0x7F: "OTHERWISE",
133
+ 0xC9: "WHEN",
134
+ 0xCA: "OF",
135
+ 0xCB: "ENDCASE",
136
+ 0xCC: "ELSE",
137
+ 0xCD: "ENDIF",
138
+ 0xCE: "ENDWHILE",
139
+ }
140
+
141
+ # Start from BASIC II, drop the three bytes that are now escape prefixes
142
+ # (so a bare prefix never resolves to AUTO/DELETE/LOAD), then apply the
143
+ # re-purposed single-byte slots.
144
+ _basic_v_single_byte = {
145
+ token: keyword
146
+ for token, keyword in TOKEN_TO_KEYWORD.items()
147
+ if token not in (0xC6, 0xC7, 0xC8)
148
+ }
149
+ _basic_v_single_byte.update(_BASIC_V_SINGLE_BYTE_OVERRIDES)
150
+
151
+ #: BBC BASIC V — the Archimedes / RISC OS ARM BASIC. Adds the
152
+ #: ``&C6``/``&C7``/``&C8`` escape tables and the re-purposed single-byte
153
+ #: tokens described in this module's docstring.
154
+ BASIC_V = Dialect(
155
+ name="BBC BASIC V",
156
+ single_byte=MappingProxyType(_basic_v_single_byte),
157
+ escape=MappingProxyType(
158
+ {
159
+ 0xC6: _extended_table(_EXTENDED_FUNCTIONS),
160
+ 0xC7: _extended_table(_EXTENDED_COMMANDS),
161
+ 0xC8: _extended_table(_EXTENDED_STATEMENTS),
162
+ }
163
+ ),
164
+ )
@@ -26,13 +26,14 @@ import enum
26
26
  from collections.abc import Iterator
27
27
  from dataclasses import dataclass
28
28
 
29
+ from oaknut.basic.dialect import BASIC_II, Dialect
29
30
  from oaknut.basic.exceptions import (
30
31
  InvalidLineLengthError,
31
32
  MissingLineMarkerError,
32
33
  TruncatedProgramError,
33
34
  )
34
35
  from oaknut.basic.linenumber import decode_line_number
35
- from oaknut.basic.tokens import HEADER_LENGTH, LINE_NUMBER_TOKEN, TOKEN_TO_KEYWORD
36
+ from oaknut.basic.tokens import HEADER_LENGTH, LINE_NUMBER_TOKEN
36
37
 
37
38
  _CR = 0x0D
38
39
  _END_MARKER = 0xFF
@@ -90,7 +91,7 @@ class LineRecord:
90
91
  start: int
91
92
 
92
93
 
93
- def scan(data: bytes, *, base_offset: int = 0) -> Iterator[Token]:
94
+ def scan(data: bytes, *, base_offset: int = 0, dialect: Dialect = BASIC_II) -> Iterator[Token]:
94
95
  """Scan an unframed line body into a stream of :class:`Token`.
95
96
 
96
97
  Args:
@@ -99,10 +100,17 @@ def scan(data: bytes, *, base_offset: int = 0) -> Iterator[Token]:
99
100
  base_offset: Added to every token's :attr:`~Token.start`, so
100
101
  offsets can be reported against a larger buffer (for example
101
102
  the line body's position within a whole program).
103
+ dialect: The BBC BASIC variant whose token tables to use.
104
+ Defaults to :data:`~oaknut.basic.BASIC_II`; pass
105
+ :data:`~oaknut.basic.BASIC_V` to resolve the
106
+ ``&C6``/``&C7``/``&C8`` two-byte escape tokens and the
107
+ re-purposed single-byte tokens of the Archimedes language.
102
108
 
103
109
  Yields:
104
110
  One :class:`Token` per keyword, literal run, string literal, and
105
- ``&8D`` line-number reference, in source order.
111
+ ``&8D`` line-number reference, in source order. For a two-byte
112
+ escape keyword the token's :attr:`~Token.token` is the prefix
113
+ byte and its :attr:`~Token.start` is the prefix's offset.
106
114
 
107
115
  Raises:
108
116
  DetokeniseError: A ``&8D`` reference is truncated.
@@ -141,13 +149,20 @@ def scan(data: bytes, *, base_offset: int = 0) -> Iterator[Token]:
141
149
  yield Token(TokenKind.LINENUM, str(decode_line_number(payload)), base_offset + i)
142
150
  i += 4
143
151
  continue
144
- if b >= 0x80 and b in TOKEN_TO_KEYWORD:
152
+ if b in dialect.escape and i + 1 < n and data[i + 1] in dialect.escape[b]:
145
153
  yield from flush()
146
- yield Token(TokenKind.KEYWORD, TOKEN_TO_KEYWORD[b], base_offset + i, b)
154
+ keyword = dialect.escape[b][data[i + 1]]
155
+ yield Token(TokenKind.KEYWORD, keyword, base_offset + i, b)
156
+ i += 2
157
+ continue
158
+ if b in dialect.single_byte:
159
+ yield from flush()
160
+ yield Token(TokenKind.KEYWORD, dialect.single_byte[b], base_offset + i, b)
147
161
  i += 1
148
162
  continue
149
- # A literal byte (< &80) or an unknown high byte (e.g. the &CE
150
- # gap): coalesce into a TEXT run so the byte still round-trips.
163
+ # A literal byte, an unknown high byte, or an escape prefix whose
164
+ # following byte has no extended-token meaning: coalesce into a
165
+ # TEXT run so the byte still round-trips.
151
166
  if text_start < 0:
152
167
  text_start = i
153
168
  text_chars.append(chr(b))
@@ -155,12 +170,15 @@ def scan(data: bytes, *, base_offset: int = 0) -> Iterator[Token]:
155
170
  yield from flush()
156
171
 
157
172
 
158
- def scan_program(data: bytes) -> Iterator[LineRecord]:
173
+ def scan_program(data: bytes, *, dialect: Dialect = BASIC_II) -> Iterator[LineRecord]:
159
174
  """Walk a ``&0D``-framed stored program, scanning each line body.
160
175
 
161
176
  Args:
162
177
  data: A tokenised program as stored on disc or in memory,
163
178
  terminated by the ``&0D &FF`` end marker.
179
+ dialect: The BBC BASIC variant whose token tables to use, passed
180
+ through to :func:`scan`. Defaults to
181
+ :data:`~oaknut.basic.BASIC_II`.
164
182
 
165
183
  Yields:
166
184
  One :class:`LineRecord` per line, in storage order, each carrying
@@ -187,6 +205,6 @@ def scan_program(data: bytes) -> Iterator[LineRecord]:
187
205
  if length < HEADER_LENGTH or i + length > n:
188
206
  raise InvalidLineLengthError(offset=i, length=length)
189
207
  body = data[i + HEADER_LENGTH : i + length]
190
- tokens = tuple(scan(body, base_offset=i + HEADER_LENGTH))
208
+ tokens = tuple(scan(body, base_offset=i + HEADER_LENGTH, dialect=dialect))
191
209
  yield LineRecord(line_number=line_number, tokens=tokens, start=i)
192
210
  i += length
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oaknut-basic
3
- Version: 12.10.1
3
+ Version: 12.12.0
4
4
  Summary: BBC BASIC tools: program tokeniser/de-tokeniser and PRINT#/INPUT# data-file reader/writer
5
5
  Author-email: Robert Smallshire <robert@smallshire.org.uk>
6
6
  License-Expression: MIT
@@ -4,7 +4,9 @@ pyproject.toml
4
4
  src/oaknut/basic/__init__.py
5
5
  src/oaknut/basic/cli.py
6
6
  src/oaknut/basic/datafile.py
7
+ src/oaknut/basic/detect.py
7
8
  src/oaknut/basic/detokeniser.py
9
+ src/oaknut/basic/dialect.py
8
10
  src/oaknut/basic/exceptions.py
9
11
  src/oaknut/basic/float5.py
10
12
  src/oaknut/basic/linenumber.py
@@ -19,10 +21,12 @@ src/oaknut_basic.egg-info/entry_points.txt
19
21
  src/oaknut_basic.egg-info/requires.txt
20
22
  src/oaknut_basic.egg-info/top_level.txt
21
23
  tests/test_basic.py
24
+ tests/test_basic_v.py
22
25
  tests/test_cli.py
23
26
  tests/test_crunch_rules.py
24
27
  tests/test_data_cli.py
25
28
  tests/test_datafile.py
29
+ tests/test_detect.py
26
30
  tests/test_detokeniser.py
27
31
  tests/test_float5.py
28
32
  tests/test_keyword_coverage.py
@@ -0,0 +1,195 @@
1
+ """Tests for the BBC BASIC V (Archimedes/RISC OS) detokenising dialect.
2
+
3
+ BASIC V re-uses three BASIC II command tokens — ``&C6``/``&C7``/``&C8`` —
4
+ as two-byte *escape prefixes*: the following byte selects an extended
5
+ keyword (``SUM``, ``RENUMBER``, ``CASE``/``SYS``/``ORIGIN`` ...). It also
6
+ re-purposes several single-byte slots: ``&7F`` becomes ``OTHERWISE`` and
7
+ ``&C9``-``&CE`` — ``LIST``/``NEW``/``OLD``/``RENUMBER``/``SAVE`` in
8
+ BASIC II — become the block keywords ``WHEN``/``OF``/``ENDCASE``/
9
+ ``ELSE``/``ENDIF``/``ENDWHILE``. The default dialect stays BASIC II, so
10
+ the escape bytes still decode to ``AUTO``/``DELETE``/``LOAD`` unless the
11
+ caller opts into :data:`BASIC_V`.
12
+ """
13
+
14
+ import pytest
15
+ from oaknut.basic import (
16
+ BASIC_II,
17
+ BASIC_V,
18
+ Dialect,
19
+ TokenKind,
20
+ detokenise,
21
+ detokenise_body,
22
+ scan,
23
+ )
24
+
25
+
26
+ def _program(*lines: tuple[int, bytes]) -> bytes:
27
+ """Frame (line_number, body) pairs into a tokenised program."""
28
+ out = bytearray()
29
+ for line_number, body in lines:
30
+ out += bytes((0x0D, (line_number >> 8) & 0xFF, line_number & 0xFF, 4 + len(body)))
31
+ out += body
32
+ out += b"\x0d\xff"
33
+ return bytes(out)
34
+
35
+
36
+ class TestEscapeStatements:
37
+ """``&C8`` <byte> — extended statement tokens, second byte from &8E."""
38
+
39
+ @pytest.mark.parametrize(
40
+ ("second", "keyword"),
41
+ [
42
+ (0x8E, "CASE"),
43
+ (0x8F, "CIRCLE"),
44
+ (0x90, "FILL"),
45
+ (0x91, "ORIGIN"),
46
+ (0x92, "POINT"),
47
+ (0x93, "RECTANGLE"),
48
+ (0x94, "SWAP"),
49
+ (0x95, "WHILE"),
50
+ (0x96, "WAIT"),
51
+ (0x97, "MOUSE"),
52
+ (0x98, "QUIT"),
53
+ (0x99, "SYS"),
54
+ (0x9A, "INSTALL"),
55
+ (0x9B, "LIBRARY"),
56
+ (0x9C, "TINT"),
57
+ (0x9D, "ELLIPSE"),
58
+ (0x9E, "BEATS"),
59
+ (0x9F, "TEMPO"),
60
+ (0xA0, "VOICES"),
61
+ (0xA1, "VOICE"),
62
+ (0xA2, "STEREO"),
63
+ (0xA3, "OVERLAY"),
64
+ ],
65
+ )
66
+ def test_c8_escape_decodes_to_statement(self, second: int, keyword: str):
67
+ assert detokenise_body(bytes((0xC8, second)), dialect=BASIC_V) == keyword
68
+
69
+
70
+ class TestEscapeCommands:
71
+ """``&C7`` <byte> — extended command tokens, second byte from &8E."""
72
+
73
+ @pytest.mark.parametrize(
74
+ ("second", "keyword"),
75
+ [
76
+ (0x8E, "APPEND"),
77
+ (0x8F, "AUTO"),
78
+ (0x90, "CRUNCH"),
79
+ (0x91, "DELETE"),
80
+ (0x92, "EDIT"),
81
+ (0x93, "HELP"),
82
+ (0x94, "LIST"),
83
+ (0x95, "LOAD"),
84
+ (0x96, "LVAR"),
85
+ (0x97, "NEW"),
86
+ (0x98, "OLD"),
87
+ (0x99, "RENUMBER"),
88
+ (0x9A, "SAVE"),
89
+ (0x9B, "TEXTLOAD"),
90
+ (0x9C, "TEXTSAVE"),
91
+ (0x9D, "TWIN"),
92
+ (0x9E, "TWINO"),
93
+ (0x9F, "INSTALL"),
94
+ ],
95
+ )
96
+ def test_c7_escape_decodes_to_command(self, second: int, keyword: str):
97
+ assert detokenise_body(bytes((0xC7, second)), dialect=BASIC_V) == keyword
98
+
99
+
100
+ class TestEscapeFunctions:
101
+ """``&C6`` <byte> — the two extended function tokens."""
102
+
103
+ @pytest.mark.parametrize(("second", "keyword"), [(0x8E, "SUM"), (0x8F, "BEAT")])
104
+ def test_c6_escape_decodes_to_function(self, second: int, keyword: str):
105
+ assert detokenise_body(bytes((0xC6, second)), dialect=BASIC_V) == keyword
106
+
107
+
108
+ class TestSingleByteDifferences:
109
+ """Single-byte tokens BASIC V re-purposes from BASIC II."""
110
+
111
+ @pytest.mark.parametrize(
112
+ ("token", "keyword"),
113
+ [
114
+ (0x7F, "OTHERWISE"),
115
+ (0xC9, "WHEN"),
116
+ (0xCA, "OF"),
117
+ (0xCB, "ENDCASE"),
118
+ (0xCC, "ELSE"),
119
+ (0xCD, "ENDIF"),
120
+ (0xCE, "ENDWHILE"),
121
+ ],
122
+ )
123
+ def test_single_byte_token_decodes_to_basic_v_keyword(self, token: int, keyword: str):
124
+ assert detokenise_body(bytes((token,)), dialect=BASIC_V) == keyword
125
+
126
+ def test_shared_single_byte_tokens_are_unchanged(self):
127
+ # Tokens common to both dialects still decode the same way.
128
+ assert detokenise_body(b"\xf1", dialect=BASIC_V) == "PRINT"
129
+ assert detokenise_body(b"\x8f", dialect=BASIC_V) == "PTR"
130
+
131
+
132
+ class TestIssue44Reproduction:
133
+ def test_origin_statement_is_not_loadtime(self):
134
+ # Line 140 of "Herding (RR1)", Acorn User June 1991:
135
+ # C8 91 " 640,512" -> ORIGIN 640,512 (was: LOADTIME 640,512)
136
+ body = bytes((0xC8, 0x91)) + b" 640,512"
137
+ assert detokenise_body(body, dialect=BASIC_V) == "ORIGIN 640,512"
138
+
139
+ def test_program_round_through_detokenise(self):
140
+ program = _program((140, bytes((0xC8, 0x91)) + b" 640,512"))
141
+ assert detokenise(program, dialect=BASIC_V) == "140ORIGIN 640,512\n"
142
+
143
+
144
+ class TestDefaultDialectUnchanged:
145
+ """Without opting in, the escape bytes keep their BASIC II meaning."""
146
+
147
+ def test_c8_defaults_to_load_plus_token(self):
148
+ body = bytes((0xC8, 0x91)) + b" 640,512"
149
+ assert detokenise_body(body) == "LOADTIME 640,512"
150
+
151
+ def test_default_dialect_is_basic_ii(self):
152
+ body = bytes((0xC8, 0x91))
153
+ assert detokenise_body(body, dialect=BASIC_II) == detokenise_body(body)
154
+
155
+ def test_basic_v_c9_is_list_under_basic_ii(self):
156
+ assert detokenise_body(b"\xc9") == "LIST"
157
+
158
+
159
+ class TestScanStream:
160
+ def test_escape_token_is_one_keyword_advancing_two_bytes(self):
161
+ body = bytes((0xC8, 0x99)) + b"X" # SYS then a literal X
162
+ tokens = list(scan(body, dialect=BASIC_V))
163
+ assert tokens[0].kind is TokenKind.KEYWORD
164
+ assert tokens[0].value == "SYS"
165
+ assert tokens[0].start == 0
166
+ assert tokens[1].kind is TokenKind.TEXT
167
+ assert tokens[1].value == "X"
168
+ assert tokens[1].start == 2
169
+
170
+ def test_escape_byte_inside_string_stays_literal(self):
171
+ body = b'"' + bytes((0xC8, 0x91)) + b'"'
172
+ tokens = list(scan(body, dialect=BASIC_V))
173
+ assert len(tokens) == 1
174
+ assert tokens[0].kind is TokenKind.STRING
175
+ assert tokens[0].value == '"\xc8\x91"'
176
+
177
+ def test_unterminated_escape_prefix_stays_literal(self):
178
+ # A trailing &C8 with no following byte must not over-read.
179
+ tokens = list(scan(b"\xc8", dialect=BASIC_V))
180
+ assert tokens[0].kind is TokenKind.TEXT
181
+ assert tokens[0].value == "\xc8"
182
+
183
+
184
+ class TestDialectObject:
185
+ def test_basic_v_has_a_name(self):
186
+ assert isinstance(BASIC_V, Dialect)
187
+ assert "V" in BASIC_V.name
188
+
189
+ def test_unknown_escape_second_byte_is_left_literal(self):
190
+ # &C8 followed by a byte with no extended-token meaning: emit the
191
+ # prefix as a literal rather than inventing a keyword or over-reading.
192
+ body = bytes((0xC8, 0x41)) # &41 = 'A', not an extended statement
193
+ tokens = list(scan(body, dialect=BASIC_V))
194
+ assert tokens[0].kind is TokenKind.TEXT
195
+ assert tokens[0].value.startswith("\xc8")
@@ -9,6 +9,7 @@ rather than re-testing the numbering or tokenising logic itself.
9
9
  from pathlib import Path
10
10
 
11
11
  import oaknut.basic as basic
12
+ import pytest
12
13
  from click.testing import CliRunner
13
14
  from oaknut.basic.cli import cli
14
15
 
@@ -230,6 +231,84 @@ class TestDetokeniseCommand:
230
231
  assert result.exit_code != 0
231
232
  assert "offset 0" in result.output
232
233
 
234
+ @pytest.mark.parametrize("dialect", ["v", "5"])
235
+ def test_dialect_v_decodes_escape_tokens(self, dialect: str):
236
+ # &C8 &91 is the BASIC V ORIGIN statement, not "LOADTIME".
237
+ # The Roman "v" and Arabic "5" numerals are interchangeable.
238
+ program = b"\x0d\x00\x8c\x0e\xc8\x91 640,512\x0d\xff"
239
+ runner = CliRunner()
240
+ result = runner.invoke(cli, ["detokenise", "--dialect", dialect], input=program)
241
+ assert result.exit_code == 0
242
+ assert result.stdout_bytes == b"140ORIGIN 640,512\n"
243
+
244
+ @pytest.mark.parametrize("dialect", ["ii", "2"])
245
+ def test_dialect_ii_keeps_basic_ii_meaning(self, dialect: str):
246
+ program = b"\x0d\x00\x8c\x0e\xc8\x91 640,512\x0d\xff"
247
+ runner = CliRunner()
248
+ result = runner.invoke(cli, ["detokenise", "--dialect", dialect], input=program)
249
+ assert result.exit_code == 0
250
+ assert result.stdout_bytes == b"140LOADTIME 640,512\n"
251
+
252
+ def test_default_dialect_is_basic_ii(self):
253
+ program = b"\x0d\x00\x8c\x0e\xc8\x91 640,512\x0d\xff"
254
+ runner = CliRunner()
255
+ result = runner.invoke(cli, ["detokenise"], input=program)
256
+ assert result.exit_code == 0
257
+ assert result.stdout_bytes == b"140LOADTIME 640,512\n"
258
+
259
+
260
+ class TestDetectCommand:
261
+ def test_stdin_basic_program_exits_zero(self):
262
+ program = basic.tokenise("10 PRINT")
263
+ runner = CliRunner()
264
+ result = runner.invoke(cli, ["detect"], input=program)
265
+ assert result.exit_code == 0
266
+ assert "BASIC" in result.output
267
+ assert "basic" in result.output
268
+
269
+ def test_stdin_non_basic_exits_nonzero(self):
270
+ runner = CliRunner()
271
+ result = runner.invoke(cli, ["detect"], input=b"just some text")
272
+ assert result.exit_code == 1
273
+ assert "not-basic" in result.output
274
+
275
+ def test_files_mixed_reports_each_and_exits_nonzero(self, tmp_path):
276
+ good = tmp_path / "PROG"
277
+ good.write_bytes(basic.tokenise("10 END"))
278
+ bad = tmp_path / "DATA"
279
+ bad.write_bytes(b"not a program")
280
+ runner = CliRunner()
281
+ result = runner.invoke(cli, ["detect", str(good), str(bad)])
282
+ assert result.exit_code == 1
283
+ assert str(good) in result.output
284
+ assert str(bad) in result.output
285
+ # One line per file.
286
+ assert result.output.count("PROG") == 1
287
+ assert result.output.count("DATA") == 1
288
+
289
+ def test_all_basic_files_exit_zero(self, tmp_path):
290
+ first = tmp_path / "A"
291
+ first.write_bytes(basic.tokenise("10 END"))
292
+ second = tmp_path / "B"
293
+ second.write_bytes(basic.tokenise("20 PRINT"))
294
+ runner = CliRunner()
295
+ result = runner.invoke(cli, ["detect", str(first), str(second)])
296
+ assert result.exit_code == 0
297
+
298
+ def test_quiet_suppresses_output_but_keeps_exit_code(self):
299
+ runner = CliRunner()
300
+ result = runner.invoke(cli, ["detect", "--quiet"], input=b"not basic")
301
+ assert result.exit_code == 1
302
+ assert result.output == ""
303
+
304
+ def test_non_ff_terminator_notes_are_shown(self):
305
+ program = bytearray(basic.tokenise("10 PRINT"))
306
+ program[-1] = 0x80 # top bit still set, so still a terminator
307
+ runner = CliRunner()
308
+ result = runner.invoke(cli, ["detect"], input=bytes(program))
309
+ assert result.exit_code == 0
310
+ assert "note:" in result.output
311
+
233
312
 
234
313
  class TestTokeniseDetokeniseRoundTrip:
235
314
  def test_cli_round_trip(self):
@@ -0,0 +1,152 @@
1
+ """Tests for the structural tokenised-BBC-BASIC detector.
2
+
3
+ The detector walks the &0D-framed line structure the same way the ROM's
4
+ ``LIST`` does, but is *tolerant*: unlike :func:`scan_program`, which
5
+ raises on malformed input, :func:`detect` classifies any blob — clean
6
+ program, program-plus-data, truncated fragment, or arbitrary garbage —
7
+ into a :class:`Verdict` with evidence, and never raises.
8
+ """
9
+
10
+ import pytest
11
+ from oaknut.basic import Detection, Verdict, detect, tokenise
12
+
13
+
14
+ def _program(*lines: tuple[int, bytes]) -> bytes:
15
+ """Frame (line_number, body) pairs into a tokenised program."""
16
+ out = bytearray()
17
+ for line_number, body in lines:
18
+ out += bytes((0x0D, (line_number >> 8) & 0xFF, line_number & 0xFF, 4 + len(body)))
19
+ out += body
20
+ out += b"\x0d\xff"
21
+ return bytes(out)
22
+
23
+
24
+ class TestClean:
25
+ def test_tokenised_program_is_basic(self):
26
+ program = tokenise("10 PRINT \"HELLO\"\n20 GOTO 10")
27
+ result = detect(program)
28
+ assert result.verdict is Verdict.BASIC
29
+ assert result.is_basic
30
+ assert result.line_count == 2
31
+ assert result.first_line == 10
32
+ assert result.last_line == 20
33
+ assert result.trailing_length == 0
34
+ assert result.program_length == len(program)
35
+ assert result.ascending
36
+
37
+ def test_reason_reports_the_line_count(self):
38
+ result = detect(tokenise("10 END"))
39
+ assert "1 line" in result.reason
40
+
41
+ def test_single_bodyless_line_is_basic(self):
42
+ # A line with only its 4-byte header (no body) is well-formed.
43
+ result = detect(_program((10, b"")))
44
+ assert result.verdict is Verdict.BASIC
45
+ assert result.line_count == 1
46
+
47
+
48
+ class TestTrailing:
49
+ def test_program_with_appended_data_is_basic_trailing(self):
50
+ program = tokenise("10 PRINT")
51
+ blob = program + b"junk appended after the terminator"
52
+ result = detect(blob)
53
+ assert result.verdict is Verdict.BASIC_TRAILING
54
+ assert result.is_basic
55
+ assert result.trailing_length == len(b"junk appended after the terminator")
56
+ assert result.program_length == len(program)
57
+ assert "trailing" in result.reason
58
+
59
+
60
+ class TestMaybe:
61
+ def test_truncated_mid_line_is_maybe(self):
62
+ # One clean line, then a second line header whose length runs off
63
+ # the end: it begins as BASIC but the structure breaks.
64
+ blob = _program((10, b"PRINT"))[:-2] # drop the terminator
65
+ blob = blob + b"\x0d\x00\x14\x40" # a line claiming 0x40 body bytes
66
+ result = detect(blob)
67
+ assert result.verdict is Verdict.MAYBE
68
+ assert not result.is_basic
69
+ assert result.line_count == 1
70
+
71
+ def test_first_line_length_too_small_after_one_good_line(self):
72
+ good = _program((10, b"AB"))[:-2] # first line, no terminator
73
+ blob = good + b"\x0d\x00\x14\x03" # second line header, length 3 (< 4)
74
+ result = detect(blob)
75
+ assert result.verdict is Verdict.MAYBE
76
+ assert "length 3" in result.reason
77
+
78
+
79
+ class TestNotBasic:
80
+ def test_not_cr_led_is_not_basic(self):
81
+ result = detect(b"This is just some text.")
82
+ assert result.verdict is Verdict.NOT_BASIC
83
+ assert not result.is_basic
84
+ assert "not a &0D line marker" in result.reason
85
+
86
+ def test_leading_cr_terminator_with_no_lines_is_not_basic(self):
87
+ # A View document opening 0x0D 0x80: a terminator before any line.
88
+ result = detect(b"\x0d\x80the rest of a view document")
89
+ assert result.verdict is Verdict.NOT_BASIC
90
+ assert result.line_count == 0
91
+
92
+ def test_too_short_is_not_basic(self):
93
+ assert detect(b"").verdict is Verdict.NOT_BASIC
94
+ assert detect(b"\x0d").verdict is Verdict.NOT_BASIC
95
+
96
+ def test_first_header_malformed_is_not_basic(self):
97
+ # 0x0D, a line-number high byte, then it runs out: zero clean lines.
98
+ result = detect(b"\x0d\x00\x0a") # truncated first header
99
+ assert result.verdict is Verdict.NOT_BASIC
100
+
101
+
102
+ class TestTerminatorTolerance:
103
+ def test_non_ff_top_bit_terminator_is_basic_with_note(self):
104
+ program = bytearray(tokenise("10 PRINT"))
105
+ # Tamper with the terminator's second byte: top bit still set.
106
+ assert program[-1] == 0xFF
107
+ program[-1] = 0x80
108
+ result = detect(bytes(program))
109
+ assert result.verdict is Verdict.BASIC
110
+ assert result.is_basic
111
+ assert any("not &FF" in note for note in result.notes)
112
+
113
+ def test_ff_terminator_has_no_note(self):
114
+ assert detect(tokenise("10 PRINT")).notes == ()
115
+
116
+
117
+ class TestEvidence:
118
+ def test_descending_line_numbers_flagged_not_ascending(self):
119
+ # Structurally valid, but line numbers go down: still BASIC.
120
+ blob = _program((20, b"A"), (10, b"B"))
121
+ result = detect(blob)
122
+ assert result.verdict is Verdict.BASIC
123
+ assert result.ascending is False
124
+ assert result.first_line == 20
125
+ assert result.last_line == 10
126
+
127
+ def test_notes_is_immutable_sequence(self):
128
+ # Evidence is a frozen result; notes must not be a mutable list a
129
+ # caller could accidentally extend.
130
+ result = detect(tokenise("10 END"))
131
+ assert isinstance(result, Detection)
132
+ with pytest.raises((AttributeError, TypeError)):
133
+ result.verdict = Verdict.NOT_BASIC # frozen dataclass
134
+
135
+
136
+ class TestNeverRaises:
137
+ @pytest.mark.parametrize(
138
+ "blob",
139
+ [
140
+ b"",
141
+ b"\x0d",
142
+ b"\x0d\x0d\x0d\x0d",
143
+ b"\x0d\x00\x0a\xff", # header length byte 0xFF
144
+ b"\x0d\x00\x0a\x04", # bodyless-length line then nothing
145
+ bytes(range(256)),
146
+ b"\x0d" + bytes(range(256)),
147
+ ],
148
+ )
149
+ def test_detect_is_total(self, blob: bytes):
150
+ # Must classify, never throw, whatever the input.
151
+ result = detect(blob)
152
+ assert isinstance(result.verdict, Verdict)
File without changes
File without changes
File without changes