TP3-COMPILER.md 12 KB

TP3-comp — Turbo Pascal 3 clone in GNU Modula-2 (ISO)

Status: compiler-core (Compiler.mod) + shell compiled clean with gm2 -fiso -Wall, and the ISO shell tpshell builds and runs (ESC-key driven, TP3-style DOS screen redraws). This is the working implementation companion — the human overview is TP3-COMPILER.md history summary and RESUME-TP3.md.

Build (proven, two-phase — the ONLY recipe that beats GNU gm2's

pass-3 "too many errors / too many identifiers" cap on compiler-sized whole-program programs)

gm2 -fiso -c Compiler.mod          # phase A: small per-module pass
gm2 -fiso -fgen-module-list=modules.lst -o /dev/null \
    Compiler.mod Term.o TextBuf.o Posix.o Editor.o  # phase B1: build list
gm2 -fiso -fuse-module-list=modules.lst -o tpshell \
    Shell.mod Compiler.mod Term.o TextBuf.o Posix.o Editor.o  # phase B2: link

(Equivalent make target tpshell in shell/Makefile.) The single gm2 -o whole-program pass 3 silently caps identifier/error counts — see build_tpshell.sh.)

What was changed inside Compiler.mod (all ISO-legal, gm2-verifiable)

  1. CHAR vs hex-int comparisons (lines ~829, ~1086): Modula-2 CHAR is not the ZType; ch = 09H → ORD (ch) = 09H (and likewise 0DH/0AH/0CH).
  2. Return-value discard (ISO forbids drop in statements; gm2 treats as error and has no -Wno-): added DropCh/DropB/DropC discard helpers and wrapped ~39 statement call sites of GetCh, MatchKey, NewSym, EmCall, EmJmpNear (no output reground clutter; TP3 TP5 cascadepos waitkey semantics preserved via the editor's ESC wait).
  3. AND/OR/NOT on W16-folded CARDINAL (lines ~1233/1246/1454) are illegal on ZType in gm2 → added BitAnd/BitOr/BitNot (BITSET */+/- fold, VAL-projected back to LONGINT) and rewrote the operands.
  4. Compile error-return params aligned to Compiler.def (errNo/errPos, was eNo/ePos) — gm2 ISO rejects a disparate name in the proper procedure.
  5. HALT -> HALT (0) in Shell.mod. A bare HALT (no operand) aborts under gm2 -fiso: a minimal repro exits 134 (SIGABRT) with a bare HALT but 0 with HALT (0). Quitting the shell died on SIGABRT.
  6. Module-level errNo renamed errNum. Compile's formal errNo shadowed the module variable, so errNo := errNo was a self-assignment and every error code reached the caller as 0.

The compiler's whole-program 8086-emit, TEXT-equivalent, pastres and errexit flow are otherwise unchanged (mirrors TP3 TPSRC file, ConvertTP3).

Lexer/parser bug class: "Alpha test without Skip"

The single largest source of wrong behaviour. Skip () is what folds blanks, { } and (* *) comments, and CR; neither PeekKw nor MatchKey leaves the cursor past it (PeekKw save/restores srcPos, MatchKey stops right after the word it matched). So a routine entered immediately after a keyword sees the CR/blank that follows that keyword, and any

IF NOT Alpha (CurCh ()) THEN Err (EUnknown) ; RETURN

fires with EUnknown (41) pointing at a blank. That is why every program used to fail on program's program name, and why writeln(...) failed on its (.

Fixed by adding the missing Skip () at each entry point (it is idempotent, so it is safe even where a Skip already ran):

routine entered after
program header PROGRAM
DefVar VAR
DefConst CONST
DefType TYPE
ParseType : / =
ProcFunc (name) PROCEDURE / FUNCTION
ProcFunc (parameter name) (, or a var-parameter's VAR
Statmnt, TkFor branch FOR
Statmnt, TkGoto branch GOTO

DefLabelPart, MatchKey, MatchDelim and MatchAssign already had theirs.

Three related defects fixed alongside:

  • ProcFunc's parameter loop used MatchKey (tok) AND (tok = TkVar) to detect a var-parameter. MatchKey consumes the word it reads, so using it as a lookahead ate the parameter's own name. Replaced with PeekKw (non-consuming), consuming with DropB (MatchKey (tok)) only on a VAR hit.
  • ProcFunc called ParseType straight after the parameter name without consuming the : of name : type; added ExpectDelim (':', ENoSemi).
  • ParseLabelStmt consumed n : but never parsed the statement the label is attached to, so Compound then demanded a ; that does not exist in 1: x := 1. Statmnt now parses the labelled statement.

Tests

Two harnesses live in shell/tests/ -- in the repo on purpose, because /tmp is wiped between sessions and an earlier /tmp-only harness was lost with it.

run_compile_tests.sh -- compiler front end, no pty, instant

tests/CompileTest.mod links Compiler + TextBuf + Posix and reads fixture paths from stdin, one per line. For each it does exactly what the shell's LoadWorkFile does (LF -> CR normalisation, ^Z ends the text), calls Compile, and prints a one-line verdict plus a source excerpt with a caret under the error position. It rebuilds Compiler.o when the source is newer, so an edit is picked up automatically.

cd shell && tests/run_compile_tests.sh            # all fixtures
cd shell && tests/run_compile_tests.sh /some/dir  # another fixture set
cd shell && tests/run_compile_tests.sh            # all fixtures
cd shell && tests/run_compile_tests.sh /some/dir  # another fixture set

Current matrix (23 fixtures) -- 17 compile, up from 1 (the empty program) when this work started:

fixture result
t01_minimal program t01; begin end. OK code=26
t04_var var decl + assignment + writeln(x) OK code=45
t06_two_args writeln('a','b') OK code=49
t07_big 10 assignments + writeln(x) OK code=99
t08_const const decls, 'A' char const OK code=32
t09_if if/then/else OK code=64
t10_while while OK code=72
t11_for for/to OK code=66
t12_repeat repeat/until OK code=69
t13_proc procedure + value parameter OK code=50
t15_label label + goto OK code=35
t16_str1 writeln('a') (1-char literal) OK code=39
t18_writeln_bare writeln with no parens OK code=29
t19_int1 writeln(1) OK code=39
t20_str3 writeln('a','b','c') OK code=59
t21_mixed writeln(1,'a',2) OK code=59
t22_case case x of 1: ..; 2: .. end OK code=93
t02, t03, t05, t17 multi-char string literal ERROR 102 (ENoLib)
t14_types array [1..5] of integer ERROR 102 (ENoLib)
uierror deliberate x := 1 + ; ERROR 41 (used by uitest.py)

code= is the emitted image size in bytes; 26 is just the prologue, so these are real code sizes, not placeholders.

Hex-dumping the emitted image

A line reading @dump on stdin switches on a hex dump of the emitted 8086 code (via the new Compiler.CodeByteAt), which is how the rel16 off-by-2 below was found -- sizes alone could never have shown it:

cd shell
printf '@dump\ntests/fixtures/t19_int1.pas\n' | ./compiletest
0000:  01 00 02 00 10 00 00 00 00 00 10 00 00 00 00 00
0010:  E8 F5 FF 8B EC B8 01 00 50 E8 04 00 83 C4 02 E8
0020:  1E 00 33 C0 E8 E9 FF

which reads: header words (CS=1, DS=0x10 = 256/16, 16 open files), CALL 8H (initmem), MOV BP,SP, MOV AX,1, PUSH AX, CALL 20H (WrInt), ADD SP,2, CALL 40H (WrLn), XOR AX,AX, CALL 10H (ProgEnd).

uitest.py -- shell/editor behaviour that only exists interactively

Drives a real pty (ptyharness.py supplies read-until-quiet, key sending and a small VT100 emulator) and asserts the TP3 compile-error jump: W load -> C compile -> ESC -> the editor opens with the cursor exactly on the error position -> Ctrl-K D back to the menu -> Q exit 0. 10/10 pass on uierror.pas, and the reported error is 41 -- i.e. the errNo self-assignment fix is now proven through the real UI. The fixture carries a deliberate syntax error (x := 1 + ; -> error 41 at line 9, column 12) rather than a missing library feature, on purpose: the latter move as the compiler grows, and a UI test whose expectations drift with it stops being a test.

The jump mirrors original TP3 kcwait + editor2 (Resources/turbopascal3source/TP3/TPSRC5:333-336 and :919): waitesc; BX:=txerrpos; DEC BX; JMP editor2, where editor2 then does ADD BX,txbeg; INC BX -- the DEC/INC cancel, so the net position is txbeg+txerrpos = our 0-based errPos. It is armed as a sticky position (Editor.GotoOffset) instead of by changing Run's signature, so Editor.def stays additive.

Note LoadWorkFile ends with a Pause, so a driver must send one filler key after the path; skipping it desynchronises every later keypress.

Standard procedures (the builtin table)

Inittur defines six predefined types (INTEGER/BYTE/CHAR/BOOLEAN/REAL/ STRING), TRUE/FALSE and two temporaries, plus now five procedures via DefBuiltins: WRITE, WRITELN, READ, READLN, HALT. Before this, WRITELN was simply absent from the symbol table, Statmnt's identifier branch failed its Search, and every program that printed anything died with EUnknown (41) on the ( after the call name.

They are tagged KBuiltin, not KProc, because they are not called generically. TP3 (TPSRC8 pwriteln / pwrloop / prdtyped) does not hand the runtime a descriptor: it inspects each argument's class and emits a different call per type, so the formatting is fixed at compile time and the runtime only ever sees a value. IoCall mirrors that:

writeln(1)      ->  MOV AX,1 ; PUSH AX ; CALL 20H ; ADD SP,2 ; CALL 40H
writeln('a')    ->  MOV AX,'a' ; PUSH AX ; CALL 28H ; ADD SP,2 ; CALL 40H
writeln(1,'a')  ->  ... CALL 20H ... CALL 28H ... CALL 40H
readln(x)       ->  LEA AX,[0104] ; PUSH AX ; CALL 48H ; ADD SP,2 ; CALL 60H

TU_WrInt/Char/Bool/Real, TU_WrLn, TU_RdInt/Char/Bool, TU_RdLn and TU_Halt are new image-base entry constants continuing the existing TU_* space (TU_InitMem=8H, TU_ProgEnd=10H, TU_StackChk=18H). READ/READLN push the address of the variable (new EmPushVarAddr: 8D 46 disp / 8D 06 off) so the runtime can store; a non-variable argument is ETypeErr (56), as in TP3.

A single-character literal needed care. RdConst reports 'a' as TScalar (its char code 97) and only longer literals as TString. That is right for c := 'a' but would make writeln('a') print 97, so ERes grew a chr flag, set in ParseAtom and honoured in IoCall, which then picks the char entry. Without it writeln('a') compiled cleanly and printed the wrong thing -- a green test lying, which is worse than a failure.

Still pending, and deliberately reported rather than faked: the runtime blob itself (CmdRun, the interpreter, and the linker that rebases these entry offsets by the runtime's size) and the string runtime, so multi-character literals still raise ENoLib (102). ENoLib is also the path for real/set/record/file and for any type wider than 2 bytes.

Codegen bug: every direct CALL/JMP was 2 bytes long

Found by dumping the emitted image, not by size. EmCall, EmJmpNear and EmJcc computed their rel16 as

rel := (target + 10000H - pc) MOD 10000H

but at that point pc already points past the opcode and at the displacement field -- the instruction does not end until pc + 2, and x86 measures rel16 from the end of the instruction. So every resolved-immediately branch and jump landed 2 bytes past its target. ResolvePatches, used for the forward patched path, already had it right (target - (place + 2)), which is why forward gotos looked fine and backward ones did not. All three now use pc + 2.

This was silently harmless so far only because nothing executes the image yet. Verified on t10_while: the JZ forward patch lands on the instruction after the loop, and the JMP back-edge now lands exactly on the loop head instead of 2 bytes into it.

gm2 pitfall: EXIT inside the program-header WHILE crashes pass 3

Adding an EXIT to the program-header parameter loop (to guard against non-advancing input on malformed input such as program p(1;)) ICEs gm2:

internal compiler error: Abandon
  ... ExitStatement -> PopExit -> M2StackWord_PopWord -> invalidloc

Extracting the loop into its own procedure did not help. So the header fix is deliberately exactly one added Skip () and no EXIT. If that loop must be hardened, use a BOOLEAN "advanced" flag, never EXIT.

Generated: 2026-09-26