oaknut-basic 12.15.0__tar.gz → 12.16.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.
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/PKG-INFO +1 -1
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/__init__.py +3 -2
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/cli.py +22 -2
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/tokeniser.py +103 -7
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/PKG-INFO +1 -1
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/SOURCES.txt +1 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_cli.py +20 -0
- oaknut_basic-12.16.0/tests/test_greedy_crunch.py +150 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/LICENSE +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/README.md +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/pyproject.toml +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/setup.cfg +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/datafile.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/detect.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/detokeniser.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/dialect.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/exceptions.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/float5.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/linenumber.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/numbering.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/scanner.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/tokens.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/dependency_links.txt +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/entry_points.txt +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/requires.txt +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/top_level.txt +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_basic.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_basic_v.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_crunch_rules.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_data_cli.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_datafile.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_detect.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_detokeniser.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_float5.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_keyword_coverage.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_linenumber.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_numbering.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_rom_golden.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_rom_golden_detokenise.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_scanner.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_tokeniser.py +0 -0
- {oaknut_basic-12.15.0 → oaknut_basic-12.16.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.
|
|
3
|
+
Version: 12.16.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
|
|
@@ -67,7 +67,7 @@ from oaknut.basic.scanner import (
|
|
|
67
67
|
scan,
|
|
68
68
|
scan_program,
|
|
69
69
|
)
|
|
70
|
-
from oaknut.basic.tokeniser import tokenise
|
|
70
|
+
from oaknut.basic.tokeniser import Crunch, tokenise
|
|
71
71
|
from oaknut.basic.tokens import (
|
|
72
72
|
FLAG_CONDITIONAL,
|
|
73
73
|
FLAG_FN_PROC,
|
|
@@ -81,7 +81,7 @@ from oaknut.basic.tokens import (
|
|
|
81
81
|
TOKEN_TO_KEYWORD,
|
|
82
82
|
)
|
|
83
83
|
|
|
84
|
-
__version__ = "12.
|
|
84
|
+
__version__ = "12.16.0"
|
|
85
85
|
|
|
86
86
|
# Canonical load addresses for BBC BASIC programs on each host.
|
|
87
87
|
# Programs saved by *SAVE on a real machine use these by default.
|
|
@@ -111,6 +111,7 @@ __all__ = [
|
|
|
111
111
|
"BbcBasicDataFileBase",
|
|
112
112
|
"BbcBasicDataReader",
|
|
113
113
|
"BbcBasicDataWriter",
|
|
114
|
+
"Crunch",
|
|
114
115
|
"DataFileError",
|
|
115
116
|
"DataFileTypeMismatchError",
|
|
116
117
|
"Detection",
|
|
@@ -212,8 +212,23 @@ def _listing_to_bytes(listing: str, encoding: str) -> bytes:
|
|
|
212
212
|
default=None,
|
|
213
213
|
help="Auto-numbering increment. Defaults to 10 when only --start is given.",
|
|
214
214
|
)
|
|
215
|
+
@click.option(
|
|
216
|
+
"--crunch",
|
|
217
|
+
type=click.Choice(["rom", "greedy"]),
|
|
218
|
+
default="rom",
|
|
219
|
+
show_default=True,
|
|
220
|
+
help="Which tokeniser to emulate. \"rom\" is byte-exact to the BBC BASIC "
|
|
221
|
+
"ROM. Use \"greedy\" to reproduce the greedier third-party tokeniser "
|
|
222
|
+
"behind some early-1980s commercial programs, so their de-tokenised "
|
|
223
|
+
"source re-tokenises byte-identically.",
|
|
224
|
+
)
|
|
215
225
|
def tokenise(
|
|
216
|
-
input_stream,
|
|
226
|
+
input_stream,
|
|
227
|
+
output_stream,
|
|
228
|
+
encoding: str,
|
|
229
|
+
start: int | None,
|
|
230
|
+
step: int | None,
|
|
231
|
+
crunch: str,
|
|
217
232
|
) -> None:
|
|
218
233
|
"""Tokenise BBC BASIC source text into a stored program.
|
|
219
234
|
|
|
@@ -232,6 +247,11 @@ def tokenise(
|
|
|
232
247
|
\b
|
|
233
248
|
oaknut-basic tokenise --start 10 unnumbered.bas MENU
|
|
234
249
|
|
|
250
|
+
Pass ``--crunch greedy`` to reproduce a greedier third-party tokeniser
|
|
251
|
+
used by a class of early-1980s commercial programs; the default
|
|
252
|
+
``rom`` is byte-exact to the BBC BASIC ROM. Use it only to regenerate
|
|
253
|
+
such a program byte-for-byte from its de-tokenised source.
|
|
254
|
+
|
|
235
255
|
INPUT is read in --encoding (``utf-8`` by default, for source authored
|
|
236
256
|
in a modern editor; pass ``acorn`` for the BBC character set, e.g.
|
|
237
257
|
source taken straight off a disc image).
|
|
@@ -239,7 +259,7 @@ def tokenise(
|
|
|
239
259
|
from oaknut.basic import tokenise as tokenise_source
|
|
240
260
|
|
|
241
261
|
source = _source_to_code_points(input_stream.read(), encoding)
|
|
242
|
-
output_stream.write(tokenise_source(source, start=start, step=step))
|
|
262
|
+
output_stream.write(tokenise_source(source, start=start, step=step, crunch=crunch))
|
|
243
263
|
|
|
244
264
|
|
|
245
265
|
@cli.command()
|
|
@@ -27,6 +27,8 @@ the CLI does it at its I/O boundary.
|
|
|
27
27
|
|
|
28
28
|
from __future__ import annotations
|
|
29
29
|
|
|
30
|
+
from typing import Literal
|
|
31
|
+
|
|
30
32
|
from oaknut.basic.exceptions import (
|
|
31
33
|
AlreadyNumberedError,
|
|
32
34
|
LineNumberOrderError,
|
|
@@ -59,6 +61,13 @@ from oaknut.basic.tokens import (
|
|
|
59
61
|
_CR = 0x0D
|
|
60
62
|
_END_MARKER = 0xFF
|
|
61
63
|
|
|
64
|
+
# The crunch dialect. ``"rom"`` is byte-exact to the BBC BASIC ROM's
|
|
65
|
+
# line-input crunch (the default). ``"greedy"`` reproduces a greedier
|
|
66
|
+
# third-party tokeniser found in early-1980s commercial programs, whose
|
|
67
|
+
# keyword recognition differs from the ROM in three localised ways (see
|
|
68
|
+
# :func:`tokenise`); everything else is identical to the ROM crunch.
|
|
69
|
+
Crunch = Literal["rom", "greedy"]
|
|
70
|
+
|
|
62
71
|
|
|
63
72
|
def _split_source_lines(source: str) -> list[str]:
|
|
64
73
|
return LINE_SEPARATOR_RE.split(source)
|
|
@@ -70,7 +79,13 @@ for _keyword, _token, _flags in KEYWORDS:
|
|
|
70
79
|
_KEYWORDS_BY_FIRST.setdefault(_keyword[0], []).append((_keyword, _token, _flags))
|
|
71
80
|
|
|
72
81
|
|
|
73
|
-
def tokenise(
|
|
82
|
+
def tokenise(
|
|
83
|
+
source: str,
|
|
84
|
+
*,
|
|
85
|
+
start: int | None = None,
|
|
86
|
+
step: int | None = None,
|
|
87
|
+
crunch: Crunch = "rom",
|
|
88
|
+
) -> bytes:
|
|
74
89
|
"""Tokenise BBC BASIC II source text into a stored program.
|
|
75
90
|
|
|
76
91
|
Args:
|
|
@@ -82,6 +97,24 @@ def tokenise(source: str, *, start: int | None = None, step: int | None = None)
|
|
|
82
97
|
numbers. Defaults to 10 when only *step* is given.
|
|
83
98
|
step: Increment for auto-numbering. Defaults to 10 when only
|
|
84
99
|
*start* is given.
|
|
100
|
+
crunch: Which tokeniser to emulate. ``"rom"`` (the default) is
|
|
101
|
+
byte-exact to the BBC BASIC ROM's line-input crunch.
|
|
102
|
+
``"greedy"`` reproduces a greedier third-party tokeniser used
|
|
103
|
+
by a class of early-1980s commercial programs, so their
|
|
104
|
+
de-tokenised source re-tokenises byte-identically. It differs
|
|
105
|
+
from the ROM in three ways only:
|
|
106
|
+
|
|
107
|
+
1. A keyword interrupts a ``&`` hex constant, ending the hex
|
|
108
|
+
run where the keyword begins (``&FE60AND`` becomes ``&FE60``
|
|
109
|
+
then ``AND``, not the literal run ``&FE60A`` then ``NDROW``).
|
|
110
|
+
2. An ``FN``/``PROC`` name terminates at a following ``THEN``
|
|
111
|
+
or ``ELSE`` (the ``FLAG_START`` keywords), keeping the first
|
|
112
|
+
name character but ending the name there. Other embedded
|
|
113
|
+
keywords (``READ`` in ``PROCREADKP``) are left intact.
|
|
114
|
+
3. A conditional keyword before a name character is suppressed
|
|
115
|
+
only when that character does not itself begin a keyword, so
|
|
116
|
+
``STOP`` before ``ELSE`` tokenises while ``NEW`` in
|
|
117
|
+
``NEWKEY%`` stays literal.
|
|
85
118
|
|
|
86
119
|
Returns:
|
|
87
120
|
The tokenised program bytes, terminated by ``&0D &FF``.
|
|
@@ -118,7 +151,7 @@ def tokenise(source: str, *, start: int | None = None, step: int | None = None)
|
|
|
118
151
|
raise LineNumberOrderError(line_index, line_number, previous_number, line)
|
|
119
152
|
previous_number = line_number
|
|
120
153
|
|
|
121
|
-
body = _tokenise_body(body_text, line_index=line_index, line_text=line)
|
|
154
|
+
body = _tokenise_body(body_text, line_index=line_index, line_text=line, crunch=crunch)
|
|
122
155
|
if len(body) > MAX_BODY_LENGTH:
|
|
123
156
|
raise LineTooLongError(line_index, line_number, len(body), line)
|
|
124
157
|
|
|
@@ -174,13 +207,18 @@ def _is_hex_digit(c: str) -> bool:
|
|
|
174
207
|
return ("0" <= c <= "9") or ("A" <= c <= "F")
|
|
175
208
|
|
|
176
209
|
|
|
177
|
-
def _try_keyword(text: str, i: int) -> tuple[int, int, int] | None:
|
|
210
|
+
def _try_keyword(text: str, i: int, *, crunch: Crunch) -> tuple[int, int, int] | None:
|
|
178
211
|
"""Match a keyword at ``text[i]``.
|
|
179
212
|
|
|
180
213
|
Returns ``(token, consumed, flags)`` for the first full or
|
|
181
214
|
``.``-abbreviated match in ROM order, or ``None`` if the cursor does
|
|
182
215
|
not begin a keyword (including a conditional keyword suppressed by a
|
|
183
216
|
following name character).
|
|
217
|
+
|
|
218
|
+
Conditional suppression depends on *crunch*: the ``"rom"`` crunch
|
|
219
|
+
suppresses whenever a name character follows; ``"greedy"`` suppresses
|
|
220
|
+
only when that character does not itself begin a keyword, so a
|
|
221
|
+
conditional keyword is still recognised before ``ELSE`` and the like.
|
|
184
222
|
"""
|
|
185
223
|
group = _KEYWORDS_BY_FIRST.get(text[i])
|
|
186
224
|
if group is None:
|
|
@@ -209,14 +247,59 @@ def _try_keyword(text: str, i: int) -> tuple[int, int, int] | None:
|
|
|
209
247
|
if abbreviated:
|
|
210
248
|
return token, p + 1, flags
|
|
211
249
|
# Full match. A conditional keyword is suppressed when a name
|
|
212
|
-
# character follows, so it stays part of an identifier
|
|
250
|
+
# character follows, so it stays part of an identifier — unless
|
|
251
|
+
# the greedy crunch sees that character begin its own keyword.
|
|
213
252
|
if flags & FLAG_CONDITIONAL and i + klen < n and _is_name_char(text[i + klen]):
|
|
214
|
-
|
|
253
|
+
if crunch == "rom" or not _starts_keyword(text, i + klen):
|
|
254
|
+
return None
|
|
215
255
|
return token, klen, flags
|
|
216
256
|
return None
|
|
217
257
|
|
|
218
258
|
|
|
219
|
-
def
|
|
259
|
+
def _starts_keyword(text: str, i: int) -> bool:
|
|
260
|
+
"""True if a keyword (full or ``.``-abbreviated) begins at ``text[i]``.
|
|
261
|
+
|
|
262
|
+
A plain prefix test with no conditional suppression, used by the
|
|
263
|
+
greedy crunch to decide where a hex run or a suppressed conditional
|
|
264
|
+
keyword should yield to a fresh keyword match.
|
|
265
|
+
"""
|
|
266
|
+
group = _KEYWORDS_BY_FIRST.get(text[i])
|
|
267
|
+
if group is None:
|
|
268
|
+
return False
|
|
269
|
+
n = len(text)
|
|
270
|
+
for keyword, _token, _flags in group:
|
|
271
|
+
for p, keyword_char in enumerate(keyword):
|
|
272
|
+
if i + p >= n:
|
|
273
|
+
break
|
|
274
|
+
source_char = text[i + p]
|
|
275
|
+
if source_char == keyword_char:
|
|
276
|
+
continue
|
|
277
|
+
if source_char == ".":
|
|
278
|
+
return True
|
|
279
|
+
break
|
|
280
|
+
else:
|
|
281
|
+
return True # ran the whole keyword: a full match
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _starts_flag_start_keyword(text: str, i: int) -> bool:
|
|
286
|
+
"""True if a ``FLAG_START`` keyword (``THEN``/``ELSE``/...) begins here.
|
|
287
|
+
|
|
288
|
+
A full-match-only test (no abbreviation): the greedy crunch breaks an
|
|
289
|
+
``FN``/``PROC`` name at such a keyword.
|
|
290
|
+
"""
|
|
291
|
+
group = _KEYWORDS_BY_FIRST.get(text[i])
|
|
292
|
+
if group is None:
|
|
293
|
+
return False
|
|
294
|
+
for keyword, _token, flags in group:
|
|
295
|
+
if flags & FLAG_START and text[i : i + len(keyword)] == keyword:
|
|
296
|
+
return True
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _tokenise_body(
|
|
301
|
+
text: str, *, line_index: int, line_text: str, crunch: Crunch = "rom"
|
|
302
|
+
) -> bytearray:
|
|
220
303
|
"""Crunch one statement-text body into tokenised bytes."""
|
|
221
304
|
out = bytearray()
|
|
222
305
|
i = 0
|
|
@@ -250,6 +333,10 @@ def _tokenise_body(text: str, *, line_index: int, line_text: str) -> bytearray:
|
|
|
250
333
|
out.append(ord("&"))
|
|
251
334
|
i += 1
|
|
252
335
|
while i < n and _is_hex_digit(text[i]):
|
|
336
|
+
# Greedy crunch (rule 1): a keyword beginning inside the run
|
|
337
|
+
# ends the hex constant, so `&FE60AND` -> `&FE60`, `AND`.
|
|
338
|
+
if crunch == "greedy" and _is_letter(text[i]) and _starts_keyword(text, i):
|
|
339
|
+
break
|
|
253
340
|
out.append(ord(text[i]))
|
|
254
341
|
i += 1
|
|
255
342
|
mid = True # a value, but does not disarm (`&FF 1` encodes the 1)
|
|
@@ -304,7 +391,7 @@ def _tokenise_body(text: str, *, line_index: int, line_text: str) -> bytearray:
|
|
|
304
391
|
continue
|
|
305
392
|
|
|
306
393
|
if "A" <= c <= "W": # potential keyword
|
|
307
|
-
match = _try_keyword(text, i)
|
|
394
|
+
match = _try_keyword(text, i, crunch=crunch)
|
|
308
395
|
if match is not None:
|
|
309
396
|
token, consumed, flags = match
|
|
310
397
|
emit = token
|
|
@@ -328,6 +415,15 @@ def _tokenise_body(text: str, *, line_index: int, line_text: str) -> bytearray:
|
|
|
328
415
|
if flags & FLAG_FN_PROC:
|
|
329
416
|
name_start = i
|
|
330
417
|
while i < n and _is_name_char(text[i]):
|
|
418
|
+
# Greedy crunch (rule 2): the name breaks at a
|
|
419
|
+
# following THEN/ELSE, but the first char is always
|
|
420
|
+
# kept (`PROCWTKEYELSE...` -> name `WTKEY`, `ELSE`).
|
|
421
|
+
if (
|
|
422
|
+
crunch == "greedy"
|
|
423
|
+
and i > name_start
|
|
424
|
+
and _starts_flag_start_keyword(text, i)
|
|
425
|
+
):
|
|
426
|
+
break
|
|
331
427
|
out.append(ord(text[i]))
|
|
332
428
|
i += 1
|
|
333
429
|
if i > name_start: # consumed an identifier -> read a name
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oaknut-basic
|
|
3
|
-
Version: 12.
|
|
3
|
+
Version: 12.16.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
|
|
@@ -252,6 +252,26 @@ class TestTokeniseCommand:
|
|
|
252
252
|
assert result.exit_code != 0
|
|
253
253
|
assert "no line number" in result.output
|
|
254
254
|
|
|
255
|
+
def test_default_crunch_is_rom(self):
|
|
256
|
+
runner = CliRunner()
|
|
257
|
+
result = runner.invoke(cli, ["tokenise"], input=b"10 A=?&FE60ANDROW%\n")
|
|
258
|
+
assert result.exit_code == 0
|
|
259
|
+
assert result.stdout_bytes == basic.tokenise("10 A=?&FE60ANDROW%\n", crunch="rom")
|
|
260
|
+
|
|
261
|
+
def test_greedy_crunch_selects_the_greedier_tokeniser(self):
|
|
262
|
+
runner = CliRunner()
|
|
263
|
+
source = b"10 A=?&FE60ANDROW%\n"
|
|
264
|
+
result = runner.invoke(cli, ["tokenise", "--crunch", "greedy"], input=source)
|
|
265
|
+
assert result.exit_code == 0
|
|
266
|
+
assert result.stdout_bytes == basic.tokenise(source.decode(), crunch="greedy")
|
|
267
|
+
# The greedy result genuinely differs from the ROM default.
|
|
268
|
+
assert result.stdout_bytes != basic.tokenise(source.decode(), crunch="rom")
|
|
269
|
+
|
|
270
|
+
def test_unknown_crunch_is_rejected(self):
|
|
271
|
+
runner = CliRunner()
|
|
272
|
+
result = runner.invoke(cli, ["tokenise", "--crunch", "nope"], input=b"10 END\n")
|
|
273
|
+
assert result.exit_code != 0
|
|
274
|
+
|
|
255
275
|
|
|
256
276
|
class TestTokeniseEncoding:
|
|
257
277
|
def test_default_decodes_host_utf8_pound_sign(self):
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The greedy crunch mode (``tokenise(..., crunch="greedy")``).
|
|
2
|
+
|
|
3
|
+
The BBC BASIC ROM's crunch is only one of the tokenisers that produced
|
|
4
|
+
tokenised BBC BASIC in the wild. A class of early-1980s commercial
|
|
5
|
+
programs was crunched by a *greedier* tool whose keyword recognition
|
|
6
|
+
differs from the ROM in three specific ways, so their de-tokenised source
|
|
7
|
+
does not re-tokenise byte-identically under the ROM-accurate default.
|
|
8
|
+
|
|
9
|
+
``crunch="greedy"`` reproduces that tool. The default (``crunch="rom"``)
|
|
10
|
+
stays byte-exact to the ROM, so the two modes are pinned side by side on
|
|
11
|
+
the same source below.
|
|
12
|
+
|
|
13
|
+
The three rule differences (see :func:`oaknut.basic.tokenise`):
|
|
14
|
+
|
|
15
|
+
- **Rule 1** — a keyword interrupts a hex constant. The ROM copies every
|
|
16
|
+
``0``-``9`` / ``A``-``F`` in a ``&`` run unconditionally; greedy stops
|
|
17
|
+
the run where a keyword begins (``&FE60ANDROW%`` -> ``&FE60``, ``AND``,
|
|
18
|
+
``ROW%``).
|
|
19
|
+
- **Rule 2** — an ``FN``/``PROC`` name terminates at a ``FLAG_START``
|
|
20
|
+
keyword (``THEN``/``ELSE``). The ROM's name-skip swallows every
|
|
21
|
+
alphanumeric; greedy keeps the first name character but breaks the name
|
|
22
|
+
at a following ``THEN``/``ELSE`` — and *only* those, so a function
|
|
23
|
+
keyword embedded in a name (``READ`` in ``PROCREADKP``) is left intact.
|
|
24
|
+
- **Rule 3** — refined conditional suppression. The ROM keeps a
|
|
25
|
+
conditional keyword literal whenever a name character follows; greedy
|
|
26
|
+
does so only when that character does not itself begin a keyword, so
|
|
27
|
+
``STOP`` before ``ELSE`` tokenises while ``NEW`` in ``NEWKEY%`` stays
|
|
28
|
+
literal.
|
|
29
|
+
|
|
30
|
+
The two commercial programs under ``data/greedy`` (Voltmace / Custom
|
|
31
|
+
Video Productions, 1983) validate the whole model: their own
|
|
32
|
+
``detokenise`` output re-tokenises byte-for-byte under greedy.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import pytest
|
|
38
|
+
from oaknut.basic import detokenise, tokenise
|
|
39
|
+
|
|
40
|
+
_DATA_DIRPATH = Path(__file__).parent / "data"
|
|
41
|
+
_GREEDY_DIRPATH = _DATA_DIRPATH / "greedy"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _body(statement: str, *, crunch: str = "rom") -> bytes:
|
|
45
|
+
"""Tokenise a single body (no leading space) and return its bytes."""
|
|
46
|
+
program = tokenise("10" + statement, crunch=crunch)
|
|
47
|
+
length = program[3]
|
|
48
|
+
return program[4:length]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# (source after the line number, ROM body, greedy body). The ROM column is
|
|
52
|
+
# the current default and must not change; the greedy column reproduces the
|
|
53
|
+
# commercial files. Values transcribed from issue #48.
|
|
54
|
+
_VECTORS = [
|
|
55
|
+
# Rule 1 — a keyword interrupts a hex constant.
|
|
56
|
+
(
|
|
57
|
+
"A=?&FE60ANDROW%",
|
|
58
|
+
bytes.fromhex("41 3d 3f 26 46 45 36 30 41 4e 44 52 4f 57 25"),
|
|
59
|
+
bytes.fromhex("41 3d 3f 26 46 45 36 30 80 52 4f 57 25"),
|
|
60
|
+
),
|
|
61
|
+
# Rule 2 — an FN/PROC name breaks at a FLAG_START keyword (THEN/ELSE).
|
|
62
|
+
(
|
|
63
|
+
"IFA THENPROCWTKEYELSEPROCBKKEY",
|
|
64
|
+
bytes.fromhex(
|
|
65
|
+
"e7 41 20 8c f2 57 54 4b 45 59 45 4c 53 45 50 52 4f 43 42 4b 4b 45 59"
|
|
66
|
+
),
|
|
67
|
+
bytes.fromhex("e7 41 20 8c f2 57 54 4b 45 59 8b f2 42 4b 4b 45 59"),
|
|
68
|
+
),
|
|
69
|
+
# Rule 2 — a function keyword embedded in a name is not a break point.
|
|
70
|
+
(
|
|
71
|
+
"PROCREADKP",
|
|
72
|
+
bytes.fromhex("f2 52 45 41 44 4b 50"),
|
|
73
|
+
bytes.fromhex("f2 52 45 41 44 4b 50"),
|
|
74
|
+
),
|
|
75
|
+
# Rule 3 — a conditional keyword before another keyword tokenises.
|
|
76
|
+
(
|
|
77
|
+
"IFA=1THENSTOPELSEGOTO90",
|
|
78
|
+
bytes.fromhex("e7 41 3d 31 8c 53 54 4f 50 45 4c 53 45 47 4f 54 4f 39 30"),
|
|
79
|
+
bytes.fromhex("e7 41 3d 31 8c fa 8b e5 8d 44 5a 40"),
|
|
80
|
+
),
|
|
81
|
+
# Rule 3 — a conditional keyword before a plain name char stays literal.
|
|
82
|
+
(
|
|
83
|
+
"NEWKEY%=0",
|
|
84
|
+
bytes.fromhex("4e 45 57 4b 45 59 25 3d 30"),
|
|
85
|
+
bytes.fromhex("4e 45 57 4b 45 59 25 3d 30"),
|
|
86
|
+
),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize(
|
|
91
|
+
("source", "rom_body", "greedy_body"),
|
|
92
|
+
_VECTORS,
|
|
93
|
+
ids=[v[0] for v in _VECTORS],
|
|
94
|
+
)
|
|
95
|
+
def test_rom_default_is_unchanged(source, rom_body, greedy_body):
|
|
96
|
+
assert _body(source, crunch="rom") == rom_body
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.parametrize(
|
|
100
|
+
("source", "rom_body", "greedy_body"),
|
|
101
|
+
_VECTORS,
|
|
102
|
+
ids=[v[0] for v in _VECTORS],
|
|
103
|
+
)
|
|
104
|
+
def test_greedy_reproduces_the_greedier_tool(source, rom_body, greedy_body):
|
|
105
|
+
assert _body(source, crunch="greedy") == greedy_body
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_default_crunch_is_rom():
|
|
109
|
+
# The default must remain ROM-accurate: an un-flagged call matches "rom".
|
|
110
|
+
source = "10 A=?&FE60ANDROW%"
|
|
111
|
+
assert tokenise(source) == tokenise(source, crunch="rom")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _program_lines(program: bytes) -> dict[int, bytes]:
|
|
115
|
+
"""Split a stored program into ``{line_number: body_bytes}``."""
|
|
116
|
+
lines: dict[int, bytes] = {}
|
|
117
|
+
offset = 0
|
|
118
|
+
while offset + 3 < len(program):
|
|
119
|
+
if program[offset] != 0x0D or program[offset + 1] == 0xFF:
|
|
120
|
+
break
|
|
121
|
+
record_length = program[offset + 3]
|
|
122
|
+
number = (program[offset + 1] << 8) | program[offset + 2]
|
|
123
|
+
lines[number] = program[offset + 4 : offset + record_length]
|
|
124
|
+
offset += record_length
|
|
125
|
+
return lines
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestCommercialPrograms:
|
|
129
|
+
"""The KEYPAD / JOYSTIK Voltmace programs round-trip under greedy."""
|
|
130
|
+
|
|
131
|
+
def test_keypad_round_trips_byte_identically(self):
|
|
132
|
+
program = (_GREEDY_DIRPATH / "keypad.tokens").read_bytes()
|
|
133
|
+
assert tokenise(detokenise(program), crunch="greedy") == program
|
|
134
|
+
|
|
135
|
+
def test_joystik_round_trips_except_the_encryption_boundary_line(self):
|
|
136
|
+
# JOYSTIK's line 1890 is the file's ROL-encryption boundary line, a
|
|
137
|
+
# decode artefact of that binary rather than a tokeniser difference;
|
|
138
|
+
# every other line re-tokenises byte-for-byte under greedy.
|
|
139
|
+
program = (_GREEDY_DIRPATH / "joystik.tokens").read_bytes()
|
|
140
|
+
retokenised = tokenise(detokenise(program), crunch="greedy")
|
|
141
|
+
original = _program_lines(program)
|
|
142
|
+
rebuilt = _program_lines(retokenised)
|
|
143
|
+
differing = [n for n in original if original[n] != rebuilt.get(n)]
|
|
144
|
+
assert differing == [1890]
|
|
145
|
+
|
|
146
|
+
def test_rom_crunch_does_not_round_trip_the_greedy_files(self):
|
|
147
|
+
# Guard the premise: the ROM-accurate default cannot regenerate a
|
|
148
|
+
# program the greedier tool produced.
|
|
149
|
+
program = (_GREEDY_DIRPATH / "keypad.tokens").read_bytes()
|
|
150
|
+
assert tokenise(detokenise(program), crunch="rom") != program
|
|
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
|
{oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/dependency_links.txt
RENAMED
|
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
|