#!/usr/bin/env python3 """mkpost.py -- write capsules/v4/post79.4th from v3's POST cases. v4/tools/mkpost.py [--v3 PATH] [--report FILE] A development tool, run when the cases change; its output is committed and reviewed like any source. docs/v4.0.0/NUCLEUS.md section 6. What it does: 1. Reads v3's case tables, v3/src/test_runner/modules/*.c, in the order v3's own POST runs them. A case is a word, a name, a line of FORTH and whether an error is expected. 2. Keeps the cases for words of the FORTH-79 Required Word Set. 3. Runs every kept case, in order, in one session of the hosted v3 binary, and records what v3 did: an error or not, the data stack, and what was printed. v3 is the reference. 4. Writes the harness and one case after another as a .4th capsule: T{ DUP.basic name the case, start clean T| 5 DUP . . a line of the case, its output captured -> 5 5 }T 0 0 T= v3's stack; v3's output: length, checksum or, for a case v3 ended with an error, ->ERR in place of the last line. A case is left out, and listed in the report, when: its word reads the keyboard; it forgets a word of the system or damages it on purpose; v3 could not finish it in one session; or one of its lines cannot be cut to fit a 64-character block line. """ import argparse import os import re import subprocess import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import post79_rules as rules # noqa: E402 ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) MODULE_DIR = os.path.join(ROOT, "v3", "src", "test_runner", "modules") OUTPUT = os.path.join(ROOT, "capsules", "v4", "post79.4th") FIRST_BLOCK = 7000 LINE_MAX = 64 # v3's POST order (v3/src/test_runner/test_runner.c, test_modules[]), the # modules that test FORTH-79 words. MODULES = [ "stack_words_test.c", "return_stack_words_test.c", "memory_words_test.c", "arithmetic_words_test.c", "logical_words_test.c", "mixed_arithmetic_words_test.c", "double_words_test.c", "format_words_test.c", "string_words_test.c", "io_words_test.c", "block_words_test.c", "dictionary_words_test.c", "dictionary_manipulation_words_test.c", "vocabulary_words_test.c", "system_words_test.c", "defining_words_tests.c", "control_words_test.c", ] # The FORTH-79 Standard's Required Word Set, by the standard's own groups. # Written from the standard's word list; check it against the document # before relying on it for a claim of compliance. REQUIRED = """ DUP DROP SWAP OVER ROT PICK ROLL ?DUP DEPTH >R R> R@ < = > 0< 0= 0> D< U< NOT + - * / MOD /MOD */ */MOD 1+ 1- 2+ 2- MAX MIN ABS NEGATE D+ DNEGATE U* U/MOD AND OR XOR @ ! C@ C! ? +! MOVE CMOVE FILL DO LOOP +LOOP I J LEAVE IF ELSE THEN BEGIN UNTIL WHILE REPEAT EXIT EXECUTE CR EMIT SPACE SPACES TYPE COUNT -TRAILING KEY EXPECT QUERY WORD BASE DECIMAL . U. CONVERT <# # #S HOLD SIGN #> LIST LOAD SCR BLOCK UPDATE BUFFER SAVE-BUFFERS EMPTY-BUFFERS : ; VARIABLE CONSTANT VOCABULARY CREATE DOES> CONTEXT CURRENT FORTH DEFINITIONS ' FIND FORGET , ALLOT ." IMMEDIATE LITERAL STATE [ ] COMPILE [COMPILE] ( >IN ABORT QUIT HERE PAD BLK 79-STANDARD """.split() # Words whose cases wait for the keyboard, which a boot cannot type at. KEYBOARD = {"KEY", "EXPECT", "QUERY"} # ---- the harness: blocks FIRST_BLOCK up, FORTH-79 and the two nucleus hooks ---- HARNESS = r""" ( post79.4th -- POST for the FORTH-79 Required Word Set on v4. ) ( Generated by v4/tools/mkpost.py from v3's POST cases: do not ) ( edit. docs/v4.0.0/NUCLEUS.md section 6. ) VARIABLE T# VARIABLE TPASS VARIABLE TFAIL VARIABLE TOPEN VARIABLE TBAD VARIABLE TWANT VARIABLE TLEN VARIABLE TSUM VARIABLE TDEP CREATE TBUF 64 ALLOT CREATE TSTK 16 ALLOT CREATE TNAME 8 ALLOT 0 T# ! 0 TPASS ! 0 TFAIL ! 0 TOPEN ! : TB ( -- baddr ) TBUF 4 * ; : TN ( -- baddr ) TNAME 4 * ; ( T-EMIT is given every character a case prints: the first 255 ) ( are kept, all are counted and summed. ) : T-EMIT ( c -- ) 255 AND DUP TSUM @ 31 * + 16777215 AND TSUM ! TLEN @ 255 < IF TB TLEN @ + C! ELSE DROP THEN 1 TLEN +! ; ===== : T-CLEAR ( i*x -- ) BEGIN DEPTH WHILE DROP REPEAT ; : T. ( n -- ) 0 <# #S #> TYPE ; ( what a failing case printed, and the stack it left ) : T-SHOW ( -- ) ." out<" TB TLEN @ 255 MIN TYPE ." > stack<" TDEP @ BEGIN DUP WHILE 1- DUP TSTK + @ . REPEAT DROP ." >" ; ( the verdict on the case that is open, if one is ) : T-CLOSE ( -- ) TOPEN @ IF (CATCH) @ -1 = TWANT @ = 0= IF 1 TBAD ! THEN TBAD @ IF 1 TFAIL +! ." POST FAIL: " TN COUNT TYPE T-SHOW CR ELSE 1 TPASS +! THEN 0 TOPEN ! 0 (CATCH) ! THEN ; ===== ( T{ name begin a case: clean stack, decimal, FORTH ) : T{ ( i*x -- ) T-CLOSE T-CLEAR DECIMAL [COMPILE] FORTH DEFINITIONS BL WORD COUNT 31 MIN DUP TN C! TN 1+ SWAP CMOVE 1 T# +! 1 TOPEN ! 0 TBAD ! 0 TWANT ! 0 TLEN ! 0 TSUM ! 0 TDEP ! 1 (CATCH) ! ; ( T| ... a line of the case, what it prints captured ) : T| ' T-EMIT (EMIT-HOOK) ! ; IMMEDIATE : T-END ( -- ) 0 (EMIT-HOOK) ! DECIMAL ; ( -> ... the case is over: set its stack aside, top first ) : -> ( i*x -- ) T-END 0 BEGIN DEPTH 1 > WHILE DUP 16 < IF SWAP OVER TSTK + ! ELSE SWAP DROP 1 TBAD ! THEN 1+ REPEAT 16 MIN TDEP ! ; ===== ( ->ERR the case is over, and must have ended in an error ) : ->ERR ( i*x -- ) T-END -1 TWANT ! T-CLEAR ; ( ... }T the stack the case must have left, in order ) : }T ( i*x -- ) DEPTH TDEP @ = 0= IF 1 TBAD ! T-CLEAR EXIT THEN 0 BEGIN DEPTH 1 > WHILE SWAP OVER TSTK + @ = 0= IF 1 TBAD ! THEN 1+ REPEAT DROP ; ( n }TD only how many values the case must have left ) : }TD ( n -- ) TDEP @ = 0= IF 1 TBAD ! THEN ; ( len sum T= what the case must have printed ) : T= ( len sum -- ) TSUM @ = 0= IF 1 TBAD ! THEN TLEN @ = 0= IF 1 TBAD ! THEN ; ===== ( the tally; a failure is an error, which ends the boot ) : T-REPORT ( -- ) T-CLOSE 0 (CATCH) ! DECIMAL ." PARITY:V4_POST tests=" T# @ T. ." pass=" TPASS @ T. ." fail=" TFAIL @ T. CR TFAIL @ IF -1 NODE-ERROR ! THEN ; """ # ---- reading v3's case tables ---------------------------------------------- TOKEN = re.compile(r''' \s+ | /\*.*?\*/ | //[^\n]* | (?P"(?:\\.|[^"\\])*") | (?P[{}(),;=\[\]&*]) | (?P[A-Za-z0-9_.+\-]+) ''', re.S | re.X) ESCAPES = {"n": "\n", "t": "\t", "\\": "\\", '"': '"', "r": "\r", "0": "\0"} def unescape(lit): out, i, body = [], 0, lit[1:-1] while i < len(body): if body[i] == "\\" and i + 1 < len(body): out.append(ESCAPES.get(body[i + 1], body[i + 1])) i += 2 else: out.append(body[i]) i += 1 return "".join(out) def tokens(text): pos, out = 0, [] while pos < len(text): m = TOKEN.match(text, pos) if not m: pos += 1 continue pos = m.end() if m.group("str"): value = unescape(m.group("str")) if out and out[-1][0] == "str": # adjacent literals join out[-1] = ("str", out[-1][1] + value) else: out.append(("str", value)) elif m.group("punct"): out.append(("punct", m.group("punct"))) elif m.group("word"): out.append(("word", m.group("word"))) return out def parse_braces(toks, i): """toks[i] is '{'; returns (nested list, index after the matching '}').""" items = [] i += 1 while toks[i] != ("punct", "}"): if toks[i] == ("punct", "{"): sub, i = parse_braces(toks, i) items.append(sub) elif toks[i] == ("punct", ","): i += 1 else: items.append(toks[i][1] if toks[i][0] != "str" else ("s", toks[i][1])) i += 1 return items, i + 1 def read_module(path): """Yields (word, case name, input, should_error) for every implemented case.""" toks = tokens(open(path, encoding="utf-8", errors="replace").read()) for i, t in enumerate(toks): if t != ("word", "WordTestSuite"): continue j = i while j < len(toks) and toks[j] != ("punct", "=") and toks[j] != ("punct", ";") and toks[j] != ("punct", "("): j += 1 if j >= len(toks) or toks[j] != ("punct", "="): continue suites, _ = parse_braces(toks, j + 1) for suite in suites: if not isinstance(suite, list) or not suite or not isinstance(suite[0], tuple): continue word = suite[0][1] for case in suite[1]: if not isinstance(case, list) or len(case) < 6 or not isinstance(case[0], tuple): continue name, text = case[0][1], case[1][1] should_error, implemented = case[4] != "0", case[5] != "0" if implemented: yield word, name, text, should_error # ---- cutting a case's line to block lines ------------------------------------ STRING_OPENERS = {'."', 'S"', 'ABORT"', '.('} def atoms(text): """The line as pieces that must each stay on one line: a word, or a word that takes text together with its text. None if the line has a new-line inside such a text.""" out, i, n = [], 0, len(text) while i < n: if text[i].isspace(): i += 1 continue j = i while j < n and not text[j].isspace(): j += 1 word = text[i:j] upper = word.upper() if upper in STRING_OPENERS or upper == "(": close = ")" if upper in ("(", ".(") else '"' k = text.find(close, j + 1 if j < n else j) if k < 0: k = n - 1 piece = text[i:k + 1] if "\n" in piece: return None out.append(piece) i = k + 1 elif word == "\\": break # the rest of the line is a comment else: out.append(word) i = j return out def pack(prefix, pieces, width=LINE_MAX): """Pieces into lines no longer than `width`, each begun with `prefix` (which may be empty). None if a piece cannot fit on a line.""" head = [prefix] if prefix else [] lines, current = [], [] for piece in pieces: if len(" ".join(head + [piece])) > width: return None if current and len(" ".join(head + current + [piece])) > width: lines.append(current) current = [] current.append(piece) if current: lines.append(current) return [" ".join(head + line) for line in lines] # A store to the search order, which v3's C test runner survives and a # running FORTH system does not. DAMAGES = re.compile(r"\b(CONTEXT|CURRENT)\s+!") def forgets_system_word(text): """True if the line FORGETs a word it did not itself define. In a plain v3 session that takes the rest of the system with it, and v4 refuses.""" words = text.split() for i, word in enumerate(words[:-1]): if word.upper() == "FORGET": target = words[i + 1] made = any(words[k] == target and k > 0 and words[k - 1].upper() in (":", "CREATE", "VARIABLE", "CONSTANT", "VOCABULARY") for k in range(i)) if not made: return True return False # ---- what v3 does -------------------------------------------------------------- ANSI = re.compile(r"\x1b\[[0-9;]*m") def run_v3(v3, cases): """Runs the cases in one session. Returns a list, one entry per case: None if v3 did not get through it, else (error, stack, output).""" script = [": T| ; IMMEDIATE", ": TCLR BEGIN DEPTH WHILE DROP REPEAT ;"] for n, case in enumerate(cases): script.append('TCLR DECIMAL FORTH DEFINITIONS ." [[B%d]]"' % n) script.extend(case["lines"]) script.append('." [[E%d]]" BASE @ DECIMAL .S BASE ! ." [[S%d]]"' % (n, n)) script.append("BYE") done = subprocess.run([v3, "--log-none"], input=("\n".join(script) + "\n").encode("latin-1"), capture_output=True, timeout=600) text = ANSI.sub("", done.stdout.decode("latin-1")) # a byte is a character results = [] for n, case in enumerate(cases): begin, mid, end = "[[B%d]] ok\nok> " % n, "ok> [[E%d]]" % n, "[[S%d]]" % n b = text.find(begin) e = text.find(mid, b) if b >= 0 else -1 s = text.find(end, e) if e >= 0 else -1 if b < 0 or e < 0 or s < 0: results.append(None) continue body = text[b + len(begin):e] # one "ok> " and one " ok" or " ERROR" for every line of the case chunks = body.split("ok> ") output, error, ok = "", False, True for chunk in chunks: if chunk.endswith(" ERROR\n"): output += chunk[:-len(" ERROR\n")] error = True elif chunk.endswith(" ok\n"): output += chunk[:-len(" ok\n")] else: ok = False m = re.match(r"<(\d+)>((?: -?\d+)*) \n", text[e + len(mid):s]) if not ok or not m: results.append(None) continue stack = [int(v) for v in m.group(2).split()] results.append((error, stack[:-1], output)) # the last value is BASE return results def checksum(output): total = 0 for ch in output.encode("latin-1", errors="replace"): total = (total * 31 + ch) & 0xFFFFFF return total # ---- writing the capsule ------------------------------------------------------- def main(): ap = argparse.ArgumentParser() ap.add_argument("--v3", default=os.path.join(ROOT, "lfs", "amd64", "starforth")) ap.add_argument("--report", default=None) ap.add_argument("--dump", default=None, help="write what v3 did with every case") args = ap.parse_args() required, left_out, cases, seen = set(REQUIRED), [], [], {} for module in MODULES: for word, name, text, should_error in read_module(os.path.join(MODULE_DIR, module)): if word not in required: continue label = ("%s.%s" % (word, name))[:31] seen[label] = seen.get(label, 0) + 1 if seen[label] > 1: label = (label[:28] + "~%d" % seen[label])[:31] if word in KEYBOARD: left_out.append((label, "reads the keyboard")) continue if forgets_system_word(text): left_out.append((label, "forgets a word of the system")) continue if DAMAGES.search(text.upper()): left_out.append((label, "damages the system on purpose")) continue pieces = atoms(text) lines = pack("T|", pieces) if pieces is not None else None if not lines: left_out.append((label, "a line cannot be cut to fit a block line")) continue cases.append({"label": label, "word": word, "text": text, "lines": lines, "should_error": should_error}) # v3 is run until every case left is one it gets through while True: results = run_v3(args.v3, cases) bad = [i for i, r in enumerate(results) if r is None] if not bad: break first = bad[0] left_out.append((cases[first]["label"], "v3 did not get through it in one session")) del cases[first] blocks, current = [], [] def flush(): if current: blocks.append(list(current)) del current[:] for part in HARNESS.strip("\n").split("=====\n"): current.extend(part.strip("\n").split("\n")) flush() disagreements, applied = [], {"address": 0, "standard": 0, "extra": 0} def expectation(error, stack, output, mode="full"): """The lines that end a case.""" if error: return ["->ERR"] if mode == "depth": return ["-> %d }TD" % len(stack)] if mode == "depth+output": return pack("", ["->", str(len(stack)), "}TD", str(len(output)), str(checksum(output)), "T="]) return pack("", ["->"] + [str(v) for v in stack] + ["}T", str(len(output)), str(checksum(output)), "T="]) def emit(label, lines, ending): block = ["T{ " + label] + lines + ending if len(current) + len(block) > 16: flush() current.extend(block) dump = [] for case, (error, stack, output) in zip(cases, results): label = case["label"] dump.append("%s\n input: %s\n v3: %s stack=%s out=%r\n" % (label, " ".join(case["text"].split()), "ERROR" if error else "ok", stack, output)) if error != case["should_error"]: disagreements.append((label, "v3's table says %s, v3 %s" % ("error" if case["should_error"] else "no error", "raised one" if error else "raised none"))) if label in rules.STANDARD: rule = rules.STANDARD[label] if rule is None: # the case is dropped applied["standard"] += 1 left_out.append((label, rules.DROPPED[label])) continue lines = pack("T|", atoms(rule.get("text", case["text"]))) assert lines, label if "depth" in rule: emit(label, lines, ["-> %d }TD" % rule["depth"]]) else: emit(label, lines, expectation(rule.get("error", False), rule.get("stack", []), rule.get("out", ""))) applied["standard"] += 1 elif label in rules.ADDRESS: emit(label, case["lines"], expectation(error, stack, output, rules.ADDRESS[label])) applied["address"] += 1 else: emit(label, case["lines"], expectation(error, stack, output)) for rule in rules.EXTRA: lines = pack("T|", atoms(rule["text"])) assert lines, rule emit(rule["label"], lines, expectation(rule.get("error", False), rule.get("stack", []), rule.get("out", ""))) applied["extra"] += 1 unknown = [k for k in list(rules.STANDARD) + list(rules.ADDRESS) if k not in {c["label"] for c in cases}] assert not unknown, "rules for cases that do not exist: %s" % unknown if args.dump: open(args.dump, "w").write("\n".join(dump)) flush() blocks.append(["T-REPORT", "FORGET T# DECIMAL"]) with open(OUTPUT, "w", encoding="ascii") as out: for k, block in enumerate(blocks): assert len(block) <= 16, block out.write("Block %d\n" % (FIRST_BLOCK + k)) for line in block: assert len(line) <= LINE_MAX, line out.write(line + "\n") tested = sorted({c["word"] for c in cases} | {r["label"].rsplit(".", 1)[0] for r in rules.EXTRA}) untested = [w for w in REQUIRED if w not in set(tested)] dropped = sum(1 for v in rules.STANDARD.values() if v is None) total = len(cases) - dropped + len(rules.EXTRA) report = ["POST cases: %d, in %d blocks (%d to %d)" % (total, len(blocks), FIRST_BLOCK, FIRST_BLOCK + len(blocks) - 1), " from v3 unchanged: %d" % (len(cases) - applied["address"] - applied["standard"]), " from v3, address-dependent (post79_rules.ADDRESS): %d" % applied["address"], " rewritten or dropped for FORTH-79 (post79_rules.STANDARD): %d, of which dropped %d" % (applied["standard"], dropped), " written by hand (post79_rules.EXTRA): %d" % applied["extra"], "Required words with at least one case: %d of %d" % (len(tested), len(REQUIRED)), "Required words with no case: " + " ".join(untested), "", "Left out (%d):" % len(left_out)] report += [" %-32s %s" % item for item in left_out] report += ["", "v3 did not do what its own table expects (%d):" % len(disagreements)] report += [" %-32s %s" % item for item in disagreements] text = "\n".join(report) + "\n" write_doc(cases, results, left_out, disagreements, untested, total, applied, dropped) if args.report: open(args.report, "w").write(text) sys.stdout.write(text) DOC = os.path.join(ROOT, "docs", "v4.0.0", "POST79.md") def code(text): return "`" + " ".join(text.split()).replace("|", "\\|") + "`" def expects(rule): if rule.get("error"): return "an error" if "depth" in rule: return "%d value(s) left; output not compared" % rule["depth"] return "stack `%s`, prints %s" % (" ".join(str(v) for v in rule.get("stack", [])) or "empty", "`%s`" % rule["out"].replace("\n", "\\n") if rule.get("out") else "nothing") def write_doc(cases, results, left_out, disagreements, untested, total, applied, dropped): by_label = {c["label"]: (c, r) for c, r in zip(cases, results)} out = ["# POST for the FORTH-79 Required Word Set: where it departs from v3", "", "Written by `v4/tools/mkpost.py` from `v4/tools/post79_rules.py`; do not edit.", "Design: `NUCLEUS.md` section 6. The capsule is `capsules/v4/post79.4th`.", "", "POST runs %d cases. %d are v3's, with what the hosted v3 binary did as the" % (total, len(cases) - applied["address"] - applied["standard"]), "expected result. This file lists every other case, and why.", "", "## 1. Rewritten for FORTH-79 (%d)" % (applied["standard"] - dropped), "", "v3 departs from the standard, or the case is written for v3's machine. v4", "follows the standard (standing ruling), so the expected result is the", "standard's and not v3's.", "", "| Case | v3's line | What v3 did | As POST runs it | Must do | Why |", "|---|---|---|---|---|---|"] for label, rule in rules.STANDARD.items(): if rule is None: continue case, (error, stack, output) = by_label[label] did = "error" if error else "stack `%s`, printed `%s`" % (" ".join(map(str, stack)) or "empty", output.replace("\n", "\\n")[:40]) ran = code(rule["text"]) if "text" in rule else "unchanged" out.append("| `%s` | %s | %s | %s | %s | %s |" % (label, code(case["text"]), did, ran, expects(rule), rule["why"])) out += ["", "## 2. Address-dependent (%d)" % applied["address"], "", "The case leaves or prints a memory address, which is a different number on", "the two machines. The number of values left is checked, and what is", "printed unless an address is printed; the values are not.", "", "| Case | Line | Checked |", "|---|---|---|"] for label, mode in rules.ADDRESS.items(): out.append("| `%s` | %s | %s |" % (label, code(by_label[label][0]["text"]), "values left, and output" if mode == "depth+output" else "values left only")) out += ["", "## 3. Written by hand (%d)" % len(rules.EXTRA), "", "For required words v3 has no case for. The expected results are the", "standard's; no v3 run stands behind them.", "", "| Case | Line | Must do |", "|---|---|---|"] for rule in rules.EXTRA: out.append("| `%s` | %s | %s |" % (rule["label"], code(rule["text"]), expects(rule))) out += ["", "## 4. v3 cases left out (%d)" % len(left_out), "", "| Case | Why |", "|---|---|"] out += ["| `%s` | %s |" % item for item in left_out] out += ["", "## 5. Required words POST does not test", "", " ".join("`%s`" % w for w in untested) + ".", "", "`KEY`, `EXPECT` and `QUERY` wait for the keyboard, which a boot cannot type", "at. `QUIT` returns to the terminal without `ok`, which the capsule loader", "takes for a refused line.", "", "## 6. Where v3 did not do what its own table expects (%d)" % len(disagreements), "", "POST expects what v3 did, unless section 1 says otherwise.", "", "| Case | |", "|---|---|"] out += ["| `%s` | %s |" % item for item in disagreements] open(DOC, "w").write("\n".join(out) + "\n") if __name__ == "__main__": main()