Pārlūkot izejas kodu

Compiler: fix the "Alpha test without Skip" bug class (9 sites)

Every parser routine entered immediately after a keyword saw the CR/blank
that followed it, so `IF NOT Alpha (CurCh ()) THEN Err (EUnknown)` fired
with a caret on a blank.  Added the missing `Skip ()` in the program
header, DefVar, DefConst, DefType, ParseType, ProcFunc (name and parameter
name), and the TkFor / TkGoto branches of Statmnt.  Skip is idempotent, so
the additions are safe where a Skip already ran.

Three related defects fixed with it:
- ProcFunc's parameter loop used MatchKey (which consumes) to look for
  VAR, eating the parameter's own name; now PeekKw, consuming only on a
  VAR hit.
- ProcFunc called ParseType without consuming the ':' of "name : type".
- ParseLabelStmt consumed "n :" but never parsed the labelled statement,
  so Compound then demanded a ';' that "1: x := 1" does not have.

Also: HALT -> HALT (0) (a bare HALT SIGABRTs under gm2 -fiso), and the
module-level errNo renamed errNum (Compile's formal errNo shadowed it, so
"errNo := errNo" was a self-assignment and every error code reached the
caller as 0).

Adds an in-repo test harness, because /tmp is wiped between sessions and
the previous /tmp-only pty harness was lost with it:
- tests/CompileTest.mod + run_compile_tests.sh: reads fixture paths from
  stdin, loads each the way LoadWorkFile does, calls Compile, prints a
  verdict plus a source excerpt with a caret under the error position.  No
  pty, no redraws, instant.  8 of 15 fixtures now compile, up from 1
  (empty program only).
- tests/uitest.py + ptyharness.py: 10/10 on the interactive path, proving
  the TP3 compile-error jump (kcwait + editor2, DEC BX cancelled by
  editor2's INC BX) lands the cursor on the error position, that the error
  number is now 41 and not 0, that Ctrl-K D returns to the menu, and that
  the shell exits 0.

Remaining gap, documented not hidden: Inittur defines six types and two
constants but zero procedures, so WRITELN is never in the symbol table and
every printing program dies with EUnknown (41) on the '(' after the call
name.  That is 6 of the 7 still-failing fixtures.

Also records the gm2 pass-3 ICE: an EXIT in the program-header parameter
loop crashes gm2 (ExitStatement -> PopExit -> M2StackWord_PopWord), and
extracting the loop into its own procedure did not help, so the header fix
is deliberately one Skip and no EXIT.
Eric Streit 2 nedēļas atpakaļ
vecāks
revīzija
503824fb1d

+ 5 - 1
.gitignore

@@ -1,2 +1,6 @@
 *.o
-shell/tpshell
+shell/tpshell
+shell/tpshell.lst
+shell/compiletest
+shell/tests/ct.lst
+__pycache__/

+ 139 - 5
TP3-COMPILER.md

@@ -36,13 +36,147 @@ whole-program pass 3 silently caps identifier/error counts — see
 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 unchanged (mirrors TP3 TPSRC file, ConvertTP3).
+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
-- `/tmp/tp_iso_mods.sh` two-phase build → `tpshell` 120528B ELF, runs,
-  redraws, ESC quits.  `make test` and editor/editor-round-trips covered in
-  the earlier TP3-EDITOR-PSEUDOCODE suite.
 
-*Generated: 2026-09-22*
+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
+```
+
+Current matrix (15 fixtures) -- **8 compile, up from 1**:
+
+| fixture | result |
+|---|---|
+| `t01_minimal` `program t01; begin end.` | OK code=26 |
+| `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 |
+| `t02`, `t03`, `t04`, `t05`, `t06`, `t07` (any use of `writeln`) | ERROR 41 |
+| `t14_types` `array [1..5] of integer` | ERROR 102 (`ENoLib`) |
+
+### `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 `t02_writeln.pas`, and the reported error is 41 -- i.e. the
+`errNo` self-assignment fix is now proven through the real UI.
+
+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.
+
+## Known gap: no standard procedure library
+
+`Inittur` defines six predefined *types* (INTEGER/BYTE/CHAR/BOOLEAN/REAL/
+STRING), TRUE/FALSE and two temporaries -- and **zero procedures**.  So
+`WRITELN` is never in the symbol table, the `ELSE` branch of `Statmnt` fails
+its `Search`, and every program that prints anything dies with `EUnknown`
+(41) on the `(` after the call name.  That is why 6 of the 15 fixtures still
+fail, and it is the next milestone.  `EmCallMost` already has the right
+shape: a `KProc` symbol with `defnd := TRUE` and `goPos` = runtime entry
+emits a direct near call.  The runtime entries `TU_InitMem=8H`,
+`TU_ProgEnd=10H`, `TU_StackChk=18H` are image-base offsets, i.e. the runtime
+blob is meant to be prepended at link time by the not-yet-written linker.
+`CmdRun`, the interpreter, is likewise still a stub.
+
+`ENoLib` (102) is the deliberate "not implemented yet" path for
+real/set/record/file/string and for any type wider than 2 bytes.
+
+## 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*

+ 31 - 6
shell/Compiler.mod

@@ -164,7 +164,9 @@ VAR
    codeSz, dataSz : CARDINAL ;
 
    abortFac : BOOLEAN ;
-   errNo    : CARDINAL ;
+   errNum   : CARDINAL ;        (* NOT "errNo": Compile's formal of that
+                                  name would shadow it, and the caller's
+                                  errNo would never be filled in *)
    txerrPos : CARDINAL ;
 
    lexnest  : CARDINAL ;
@@ -293,7 +295,7 @@ PROCEDURE Err (n : CARDINAL) ;
 BEGIN
    IF NOT abortFac THEN
       abortFac := TRUE ;
-      errNo := n ;
+      errNum := n ;
       txerrPos := srcPos
    END
 END Err ;
@@ -1713,7 +1715,9 @@ VAR tok : CARDINAL ;
 BEGIN
    Skip () ;
    IF Digit (CurCh ()) THEN
-      ParseLabelStmt () ;
+      ParseLabelStmt () ;      (* consumed 'n' ':' *)
+      Statmnt () ;             (* 'n : statement' - the statement follows
+                                  the label directly, with no ';' between *)
       RETURN
    END ;
    IF NOT Alpha (CurCh ()) THEN
@@ -1784,6 +1788,7 @@ BEGIN
       exitCnt := brkSave [brkN]
    ELSIF tok = TkFor THEN
       (* control variable *)
+      Skip () ;                   (* after the FOR keyword: skip blanks *)
       IF NOT Alpha (CurCh ()) THEN
          Err (EUnknown) ;
          RETURN
@@ -1925,6 +1930,7 @@ BEGIN
       END
    ELSIF tok = TkGoto THEN
       v := 0 ;
+      Skip () ;                  (* after the GOTO keyword: skip blanks *)
       IF Digit (CurCh ()) THEN
          RdIntConst (v) ;
          NumToName (W16 (v), nm) ;
@@ -2012,6 +2018,7 @@ VAR tok : CARDINAL ;
 BEGIN
    cls := TNone ; size := 0 ; elem := 0 ;
    consumed := FALSE ;
+   Skip () ;                (* after ':' / '=' : skip blanks *)
    IF Alpha (CurCh ()) THEN
       DropB (MatchKey (tok)) ;
       consumed := TRUE
@@ -2105,6 +2112,7 @@ VAR nm : ARRAY [0..MaxName] OF CHAR ;
     off : CARDINAL ;
 BEGIN
    LOOP
+      Skip () ;                (* after the VAR keyword: skip blanks *)
       IF NOT Alpha (CurCh ()) THEN
          Err (EUnknown) ;
          RETURN
@@ -2174,6 +2182,7 @@ BEGIN
       IF tok # TkNone THEN
          RETURN
       END ;
+      Skip () ;                (* after the CONST keyword: skip blanks *)
       IF NOT Alpha (CurCh ()) THEN
          Err (EUnknown) ;
          RETURN
@@ -2253,6 +2262,7 @@ VAR nm : ARRAY [0..MaxName] OF CHAR ;
     i : CARDINAL ;
 BEGIN
    isFunc := curIsFunc ;
+   Skip () ;                   (* after the PROCEDURE/FUNCTION keyword *)
    IF NOT Alpha (CurCh ()) THEN
       Err (EUnknown) ;
       RETURN
@@ -2284,9 +2294,14 @@ BEGIN
    IF MatchDelim ('(') THEN
       IF CurCh () # ')' THEN
          LOOP
-            IF MatchKey (tok) AND (tok = TkVar) THEN
+            (* PeekKw, not MatchKey: MatchKey CONSUMES the word it reads, so
+               using it to test for VAR would eat the parameter's name. *)
+            PeekKw (tok) ;
+            IF tok = TkVar THEN
                (* VAR parameter recorded as value in this milestone *)
+               DropB (MatchKey (tok))
             END ;
+            Skip () ;             (* blanks before the parameter name *)
             IF NOT Alpha (CurCh ()) THEN
                Err (EUnknown) ;
                RETURN
@@ -2294,6 +2309,7 @@ BEGIN
             GetWord () ;
             SaveWord (parmNm) ;
             DupTest (parmNm) ;
+            ExpectDelim (':', ENoSemi) ;   (* formal is 'name : type' *)
             ParseType (cls, size, elem) ;
             IF size > 2 THEN
                Err (ENoLib) ;
@@ -2391,6 +2407,7 @@ BEGIN
       IF tok # TkNone THEN
          RETURN
       END ;
+      Skip () ;                (* after the TYPE keyword: skip blanks *)
       IF NOT Alpha (CurCh ()) THEN
          Err (EUnknown) ;
          RETURN
@@ -2466,7 +2483,7 @@ PROCEDURE Inittur () ;
 (* reset compiler state and define the standard types *)
 BEGIN
    abortFac := FALSE ;
-   errNo := 0 ;
+   errNum := 0 ;
    txerrPos := 0 ;
    srcPos := 0 ;
    srcLen := Length () ;
@@ -2525,6 +2542,11 @@ BEGIN
       DropC (EmCall (TU_InitMem)) ;
       EmMovBpSp () ;
       IF MatchKey (tok) AND (tok = TkProgram) THEN
+         (* MatchKey stops right after "PROGRAM", so the optional program
+            name normally follows blanks.  Skip them before testing for the
+            name: otherwise Alpha sees the blank, the name is never consumed
+            and IfMatchSemi reports ENoSemi at the name. *)
+         Skip () ;
          IF Alpha (CurCh ()) THEN
             GetWord ()
          END ;
@@ -2572,7 +2594,10 @@ BEGIN
       Err (EPointExp)
    END ;
    IF abortFac THEN
-      errNo := errNo ;
+      errNo := errNum ;         (* was "errNo := errNo": a self-assignment,
+                                   because the formal shadowed the module
+                                   variable, so the error code always
+                                   reached the caller as 0 *)
       errPos := txerrPos ;
       RETURN FALSE
    END ;

+ 5 - 0
shell/Editor.def

@@ -8,4 +8,9 @@ DEFINITION MODULE Editor ;
 PROCEDURE Run (drive : CHAR ; VAR fileName : ARRAY OF CHAR ;
                VAR changed : BOOLEAN) ;
 
+PROCEDURE GotoOffset (off : CARDINAL) ;
+(* Arm an error jump: the next Run places the cursor on the given
+   0-based TextBuf offset (TP3 errexit + editor2 path used by CmdCompile).
+   The view scrolls to follow; ignored if no Run follows. *)
+
 END Editor.

+ 22 - 2
shell/Editor.mod

@@ -62,6 +62,9 @@ VAR
 
    endEdit    : BOOLEAN ;         (* Ctrl-K-D pressed *)
 
+   gotoPend   : BOOLEAN ;         (* enter editor at gotoOff (errexit jump) *)
+   gotoOff    : CARDINAL ;
+
    trash      : LONGINT ;         (* discarded syscall result *)
 
 PROCEDURE StrClear (VAR s : ARRAY OF CHAR) ;
@@ -156,6 +159,17 @@ BEGIN
    END
 END OffToPos ;
 
+PROCEDURE GotoOffset (off : CARDINAL) ;
+(* Arm a jump: the next Run enters the editor with the cursor on this
+   0-based TextBuf offset.  Mirrors TP3's errexit path, where the shell
+   does  waitesc ; BX:=txerrpos ; DEC BX ; JMP editor2  and editor2
+   lands on txbeg+txerrpos.  PositionCursor scrolls the view to it. *)
+VAR ln, cl : CARDINAL ;
+BEGIN
+   gotoPend := TRUE ;
+   gotoOff := off
+END GotoOffset ;
+
 PROCEDURE ClampCursor ;
 VAR last : CARDINAL ;
 BEGIN
@@ -1570,8 +1584,14 @@ BEGIN
       INC (i)
    END ;
 
-   curLine := 0 ;
-   curCol := 0 ;
+   IF gotoPend THEN
+      gotoPend := FALSE ;
+      OffToPos (gotoOff, curLine, curCol) ;
+      ClampCursor
+   ELSE
+      curLine := 0 ;
+      curCol := 0
+   END ;
    topLine := 0 ;
    colOff := 0 ;
    insertMode := TRUE ;

+ 10 - 4
shell/Shell.mod

@@ -16,7 +16,7 @@ FROM Posix IMPORT
    Dir, dirent, statvfsbuf ;
 
 FROM Compiler IMPORT Compile, CodeBytes, DataBytes ;
-FROM Editor IMPORT Run ;
+FROM Editor IMPORT Run, GotoOffset ;
 
 FROM TextBuf IMPORT
    TextLimit, Clear, Length, CharAt, InsertCh ;
@@ -644,9 +644,13 @@ BEGIN
       PutStr (" at relative pos ") ;
       PutCard (errPos) ;
       CrLf ;
-      PutStr ("(jump to editor position not yet wired)") ;
+      PutStr ("press ESC, then the editor opens on the error") ;
       CrLf ;
-      WaitEsc
+      WaitEsc ;
+      (* TP3 kcwait: waitesc ; BX:=txerrpos ; DEC BX ; JMP editor2.
+         Net cursor position = txbeg + txerrpos, i.e. errPos here. *)
+      GotoOffset (errPos) ;
+      CmdEditor
    END
 END CmdCompile ;
 
@@ -837,7 +841,9 @@ BEGIN
               END
            END ;
            Close ;
-           HALT
+           (* gm2 -fiso: a bare HALT aborts (SIGABRT, shell status 134);
+              an explicit HALT (0) exits cleanly with status 0. *)
+           HALT (0)
       ELSE
          (* any other key: redraw the menu, like TP3 *)
       END

+ 223 - 0
shell/tests/CompileTest.mod

@@ -0,0 +1,223 @@
+MODULE CompileTest ;
+
+(* Direct test harness for the TP3 compiler.
+
+   Reads fixture paths from stdin, one per line, loads each into TextBuf
+   exactly the way the shell's LoadWorkFile does, then calls Compiler.Compile
+   and prints a one-line verdict plus a source excerpt with a caret under the
+   error position.
+
+   This is the fast loop for compiler work: no pty, no editor, no screen
+   redraws - just Compile's own errNo / errPos, which is what actually
+   matters.  (The pty harness in matrix.py covers the shell/editor UI.)
+
+   Usage:  printf 'a.pas\nb.pas\n' | ./compiletest          *)
+
+FROM Posix IMPORT read, write, open, close ;
+FROM TextBuf IMPORT TextLimit, Clear, Length, CharAt, InsertCh ;
+FROM Compiler IMPORT Compile, CodeBytes, DataBytes ;
+FROM SYSTEM IMPORT ADR, BYTE ;
+
+CONST
+   STDIN = 0 ;
+   STDOUT = 1 ;
+   O_RDONLY = 0 ;
+
+VAR
+   lineBuf : ARRAY [0..255] OF CHAR ;
+   path    : ARRAY [0..511] OF CHAR ;
+   txt     : ARRAY [0..79] OF CHAR ;
+   mark    : ARRAY [0..79] OF CHAR ;
+
+PROCEDURE PutCh (ch : CHAR) ;
+VAR n : LONGINT ;
+BEGIN
+   n := write (STDOUT, ADR (ch), 1)
+END PutCh ;
+
+PROCEDURE PutStr (s : ARRAY OF CHAR) ;
+VAR i : CARDINAL ; n : LONGINT ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (s)) AND (s [i] # 0C) DO
+      n := write (STDOUT, ADR (s [i]), 1) ;
+      INC (i)
+   END
+END PutStr ;
+
+PROCEDURE PutCard (n : CARDINAL) ;
+VAR dig : ARRAY [0..9] OF CHAR ; i : CARDINAL ;
+BEGIN
+   IF n = 0 THEN
+      PutCh ("0")
+   ELSE
+      i := 0 ;
+      WHILE n > 0 DO
+         dig [i] := CHR (ORD ("0") + (n MOD 10)) ;
+         n := n DIV 10 ;
+         INC (i)
+      END ;
+      WHILE i > 0 DO
+         DEC (i) ;
+         PutCh (dig [i])
+      END
+   END
+END PutCard ;
+
+PROCEDURE NL ;
+BEGIN
+   PutCh (CHR (13)) ;
+   PutCh (CHR (10))
+END NL ;
+
+PROCEDURE StrCopy (VAR dst : ARRAY OF CHAR ; src : ARRAY OF CHAR) ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (dst)) AND (i <= HIGH (src)) AND (src [i] # 0C) DO
+      dst [i] := src [i] ;
+      INC (i)
+   END ;
+   IF i <= HIGH (dst) THEN
+      dst [i] := 0C
+   END
+END StrCopy ;
+
+PROCEDURE ReadLineStr (VAR s : ARRAY OF CHAR) : BOOLEAN ;
+(* one line from stdin; FALSE at EOF/blank line *)
+VAR ch : CHAR ; n : LONGINT ; i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   s [0] := 0C ;
+   LOOP
+      n := read (STDIN, ADR (ch), 1) ;
+      IF n # 1 THEN
+         IF i = 0 THEN
+            RETURN FALSE
+         END ;
+         s [i] := 0C ;
+         RETURN TRUE
+      END ;
+      IF (ch = CHR (10)) OR (ch = CHR (13)) THEN
+         IF i = 0 THEN
+            RETURN FALSE
+         END ;
+         s [i] := 0C ;
+         RETURN TRUE
+      END ;
+      IF i < HIGH (s) THEN
+         s [i] := ch ;
+         INC (i)
+      END
+   END
+END ReadLineStr ;
+
+PROCEDURE LoadFile (p : ARRAY OF CHAR) : BOOLEAN ;
+(* same CR/LF normalisation as the shell's LoadWorkFile *)
+VAR fd : INTEGER ; k : LONGINT ; b : BYTE ; prevCR : BOOLEAN ;
+BEGIN
+   fd := open (ADR (p), O_RDONLY, 0) ;
+   IF fd < 0 THEN
+      RETURN FALSE
+   END ;
+   Clear ;
+   prevCR := FALSE ;
+   LOOP
+      IF Length () >= TextLimit THEN
+         EXIT
+      END ;
+      k := read (fd, ADR (b), 1) ;
+      IF k # 1 THEN
+         EXIT
+      END ;
+      IF b = 26 THEN
+         EXIT                             (* ^Z ends the text *)
+      ELSIF b = 10 THEN
+         IF NOT prevCR THEN
+            InsertCh (Length (), CHR (13))  (* lone LF -> CR *)
+         END ;
+         prevCR := FALSE
+      ELSIF b = 13 THEN
+         InsertCh (Length (), CHR (13)) ;
+         prevCR := TRUE
+      ELSE
+         InsertCh (Length (), CHR (ORD (b))) ;
+         prevCR := FALSE
+      END
+   END ;
+   k := close (fd) ;
+   RETURN TRUE
+END LoadFile ;
+
+PROCEDURE ShowAt (pos : CARDINAL) ;
+(* print the source around pos, with '^' under pos *)
+VAR s, e, i : CARDINAL ;
+BEGIN
+   IF pos > 40 THEN
+      s := pos - 40
+   ELSE
+      s := 0
+   END ;
+   e := pos + 30 ;
+   IF e > Length () THEN
+      e := Length ()
+   END ;
+   IF e > s + 78 THEN
+      e := s + 78
+   END ;
+   i := 0 ;
+   WHILE s + i < e DO
+      txt [i] := CharAt (s + i) ;
+      IF txt [i] = CHR (13) THEN
+         txt [i] := " "
+      END ;
+      IF s + i = pos THEN
+         mark [i] := "^"
+      ELSE
+         mark [i] := "-"
+      END ;
+      INC (i)
+   END ;
+   txt [i] := 0C ;
+   mark [i] := 0C ;
+   PutStr ("        " ) ;
+   PutStr (txt) ;
+   NL ;
+   PutStr ("        " ) ;
+   PutStr (mark) ;
+   NL
+END ShowAt ;
+
+VAR
+   errNo, errPos : CARDINAL ;
+   ok : BOOLEAN ;
+BEGIN
+   PutStr ("FIXTURE  RESULT") ;
+   NL ;
+   WHILE ReadLineStr (lineBuf) DO
+      StrCopy (path, lineBuf) ;
+      IF LoadFile (path) THEN
+         ok := Compile (errNo, errPos) ;
+         IF ok THEN
+            PutStr (lineBuf) ;
+            PutStr ("  OK  code=") ;
+            PutCard (CodeBytes ()) ;
+            PutStr (" data=") ;
+            PutCard (DataBytes ()) ;
+            NL
+         ELSE
+            PutStr (lineBuf) ;
+            PutStr ("  ERROR ") ;
+            PutCard (errNo) ;
+            PutStr (" at pos ") ;
+            PutCard (errPos) ;
+            NL ;
+            ShowAt (errPos)
+         END
+      ELSE
+         PutStr (lineBuf) ;
+         PutStr ("  CANNOT OPEN") ;
+         NL
+      END
+   END
+END CompileTest.

+ 3 - 0
shell/tests/fixtures/t01_minimal.pas

@@ -0,0 +1,3 @@
+program t01;
+begin
+end.

+ 4 - 0
shell/tests/fixtures/t02_writeln.pas

@@ -0,0 +1,4 @@
+program t02;
+begin
+  writeln('hi')
+end.

+ 4 - 0
shell/tests/fixtures/t03_inline_comment.pas

@@ -0,0 +1,4 @@
+program t03;
+begin
+  writeln{inline}('hi')
+end.

+ 7 - 0
shell/tests/fixtures/t04_var.pas

@@ -0,0 +1,7 @@
+program t04;
+var
+  x : integer;
+begin
+  x := 1;
+  writeln(x)
+end.

+ 5 - 0
shell/tests/fixtures/t05_own_line_comment.pas

@@ -0,0 +1,5 @@
+program t05;
+begin
+  { a comment on its own line }
+  writeln('hi')
+end.

+ 4 - 0
shell/tests/fixtures/t06_two_args.pas

@@ -0,0 +1,4 @@
+program t06;
+begin
+  writeln('a', 'b')
+end.

+ 16 - 0
shell/tests/fixtures/t07_big.pas

@@ -0,0 +1,16 @@
+program t07;
+var
+  x : integer;
+begin
+  x := 0;
+  x := 1;
+  x := 2;
+  x := 3;
+  x := 4;
+  x := 5;
+  x := 6;
+  x := 7;
+  x := 8;
+  x := 9;
+  writeln(x)
+end.

+ 9 - 0
shell/tests/fixtures/t08_const.pas

@@ -0,0 +1,9 @@
+program t08;
+const
+  n = 7 ;
+  c = 'A' ;
+var
+  x : integer ;
+begin
+  x := n
+end.

+ 9 - 0
shell/tests/fixtures/t09_if.pas

@@ -0,0 +1,9 @@
+program t09;
+var
+  x : integer ;
+begin
+  if x > 0 then
+    x := 1
+  else
+    x := 2
+end.

+ 8 - 0
shell/tests/fixtures/t10_while.pas

@@ -0,0 +1,8 @@
+program t10;
+var
+  x : integer ;
+begin
+  x := 0 ;
+  while x < 10 do
+    x := x + 1
+end.

+ 7 - 0
shell/tests/fixtures/t11_for.pas

@@ -0,0 +1,7 @@
+program t11;
+var
+  i : integer ;
+begin
+  for i := 1 to 10 do
+    i := i
+end.

+ 9 - 0
shell/tests/fixtures/t12_repeat.pas

@@ -0,0 +1,9 @@
+program t12;
+var
+  i : integer ;
+begin
+  i := 0 ;
+  repeat
+    i := i + 1
+  until i > 5
+end.

+ 10 - 0
shell/tests/fixtures/t13_proc.pas

@@ -0,0 +1,10 @@
+program t13;
+var
+  x : integer ;
+procedure show (a : integer) ;
+begin
+  x := a
+end ;
+begin
+  show (3)
+end.

+ 10 - 0
shell/tests/fixtures/t14_types.pas

@@ -0,0 +1,10 @@
+program t14;
+type
+  small = 1..10 ;
+var
+  a : small ;
+  b : array [1..5] of integer ;
+begin
+  a := 1 ;
+  b [1] := 2
+end.

+ 9 - 0
shell/tests/fixtures/t15_label.pas

@@ -0,0 +1,9 @@
+program t15;
+label
+  1 ;
+var
+  x : integer ;
+begin
+  goto 1 ;
+1: x := 1
+end.

+ 170 - 0
shell/tests/ptyharness.py

@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+"""Shared pty helpers for driving tpshell non-interactively.
+
+The shell is a full-screen curses-style program: it switches the tty to raw
+mode and reads one key byte at a time (Term.GetCh), so a test has to drive it
+through a real pty, one key at a time, and wait for silence between keys.
+
+Provides:
+  drain/send          - read-until-quiet and "write keys, then settle"
+  visible()           - strip ANSI escapes from captured output
+  Screen              - minimal VT100 emulator (CSI 2J / H / r;cH / nD / m)
+                        so assertions can run against the rendered screen
+"""
+import fcntl
+import os
+import pty
+import select
+import struct
+import termios
+import time
+
+ROWS, COLS = 25, 80
+SHELL_DIR = "/home/eric/Projets/Projets-Modula2/MyWork/TP3-comp/shell"
+ESC = b"\x1b"
+CTRL_K = b"\x0b"
+
+
+def set_winsize(fd, rows=ROWS, cols=COLS):
+    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
+
+
+def drain(fd, quiet=0.45, total=8.0):
+    """Read until the child has been silent for `quiet` seconds."""
+    out = b""
+    deadline = time.time() + total
+    while time.time() < deadline:
+        r, _, _ = select.select([fd], [], [], 0.05)
+        if r:
+            try:
+                d = os.read(fd, 65536)
+            except OSError:
+                break
+            if not d:
+                break
+            out += d
+            deadline = time.time() + quiet
+    return out
+
+
+def send(fd, data, quiet=0.45):
+    """Write keys, then wait for the child to settle."""
+    os.write(fd, data)
+    return drain(fd, quiet)
+
+
+def spawn(exe="./tpshell", cwd=SHELL_DIR):
+    """Fork a pty running tpshell.  Returns (pid, fd)."""
+    os.chdir(cwd)
+    pid, fd = pty.fork()
+    if pid == 0:
+        os.environ["TERM"] = "xterm"
+        os.execv(exe, [exe])
+        os._exit(127)
+    set_winsize(fd)
+    return pid, fd
+
+
+def reap(fd, pid):
+    try:
+        os.close(fd)
+    except OSError:
+        pass
+    _, status = os.waitpid(pid, 0)
+    return status
+
+
+def status_str(status):
+    if os.WIFSIGNALED(status):
+        sig = os.WTERMSIG(status)
+        names = {6: "SIGABRT", 11: "SIGSEGV", 15: "SIGTERM", 9: "SIGKILL"}
+        return "SIGNAL %d (%s)" % (sig, names.get(sig, "?"))
+    if os.WIFEXITED(status):
+        return "EXIT %d" % os.WEXITSTATUS(status)
+    return "raw %d" % status
+
+
+def visible(b):
+    """Strip ANSI escapes from captured output (latin-1 safe)."""
+    out, i = [], 0
+    while i < len(b):
+        if b[i] == 0x1B:
+            j = i + 1
+            if j < len(b) and b[j] == ord("["):
+                j += 1
+                while j < len(b) and b[j] not in b"@ABCDEFGHJKSTfmnsulh":
+                    j += 1
+                i = j + 1
+                continue
+            i += 2
+            continue
+        out.append(b[i : i + 1])
+        i += 1
+    return b"".join(out).decode("latin-1")
+
+
+class Screen:
+    """Enough VT100 to render what Term.mod emits."""
+
+    def __init__(self, rows=ROWS, cols=COLS):
+        self.rows, self.cols = rows, cols
+        self.r = self.c = 0
+        self.clear()
+
+    def clear(self):
+        self.buf = [[" "] * self.cols for _ in range(self.rows)]
+
+    def put(self, ch):
+        if ch == "\n":
+            self.r = min(self.r + 1, self.rows - 1)
+            return
+        if ch == "\r":
+            self.c = 0
+            return
+        if 0 <= self.r < self.rows and 0 <= self.c < self.cols:
+            self.buf[self.r][self.c] = ch
+        self.c += 1
+        if self.c >= self.cols:
+            self.c = 0
+            self.r = min(self.r + 1, self.rows - 1)
+
+    def feed(self, data):
+        if isinstance(data, bytes):
+            data = data.decode("latin-1")
+        i, n = 0, len(data)
+        while i < n:
+            ch = data[i]
+            if ch == "\x1b":
+                j = i + 1
+                if j < n and data[j] == "[":
+                    j += 1
+                    start = j
+                    while j < n and data[j] not in "@ABCDEFGHJKSTfmnsulh":
+                        j += 1
+                    if j >= n:
+                        break
+                    params, final = data[start:j], data[j]
+                    if final == "H":
+                        p = params.split(";")
+                        self.r = (int(p[0]) if len(p) > 0 and p[0] else 1) - 1
+                        self.c = (int(p[1]) if len(p) > 1 and p[1] else 1) - 1
+                    elif final == "J":
+                        if params == "2":
+                            self.clear()
+                    elif final == "D":
+                        self.c = max(0, self.c - (int(params) if params else 1))
+                    i = j + 1
+                    continue
+                i += 2
+                continue
+            self.put(ch)
+            i += 1
+
+    def text(self):
+        return "\n".join("".join(row).rstrip() for row in self.buf)
+
+    def row_with(self, needle):
+        for idx, row in enumerate(self.buf):
+            if needle in "".join(row):
+                return idx
+        return None

+ 46 - 0
shell/tests/run_compile_tests.sh

@@ -0,0 +1,46 @@
+#!/bin/bash
+# Build and run the direct compiler harness (tests/CompileTest.mod).
+#
+# Same two-phase ISO link recipe as tpshell: a single monolithic invocation
+# trips gm2's pass-3 rollup on a compiler-size program, so generate the module
+# list first (-fgen-module-list -> /dev/null, rc=1 is expected) and then link
+# with -fuse-list.
+set -u
+D=/home/eric/Projets/Projets-Modula2/MyWork/TP3-comp/shell
+GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
+cd "$D" || exit 9
+FLAGS="-fiso"
+
+if [ ! -f TextBuf.o ] || [ ! -f Compiler.o ] || [ ! -f Posix.o ] \
+   || [ TextBuf.mod -nt TextBuf.o ] || [ Compiler.mod -nt Compiler.o ] \
+   || [ Posix.c -nt Posix.o ] || [ Posix.def -nt Posix.o ]; then
+   echo "building support modules (source newer than object)..."
+   # NB: Posix is a FOR "C" module (Posix.c + Posix.def, no Posix.mod), so it
+   # is not rebuilt here - only TextBuf and Compiler have a .mod to compile.
+   for m in TextBuf Compiler; do
+      $GM2 $FLAGS -c $m.mod >/tmp/ct_c_$m 2>&1
+      if [ $? -ne 0 ]; then
+         echo "COMPILE_FAIL $m"
+         grep -m5 -E "error:|Please submit" /tmp/ct_c_$m
+         exit 1
+      fi
+   done
+   echo "  support modules rebuilt"
+fi
+
+rm -f tests/ct.lst compiletest
+$GM2 $FLAGS -fgen-module-list=tests/ct.lst -o /dev/null \
+    tests/CompileTest.mod TextBuf.o Posix.o Compiler.o >/tmp/ct_p1 2>&1
+p1=$?
+$GM2 $FLAGS -fuse-list=tests/ct.lst -o compiletest \
+    tests/CompileTest.mod TextBuf.o Posix.o Compiler.o >/tmp/ct_p2 2>&1
+p2=$?
+if [ $p2 -ne 0 ]; then
+   echo "LINK_FAIL p1_rc=$p1 p2_rc=$p2"
+   grep -E "error:|undefined" /tmp/ct_p2 | head -10
+   exit 1
+fi
+echo "compiletest built (p1_rc=$p1, phase 1 rc=1 is the expected rollup)"
+
+FX=${1:-tests/fixtures}
+ls "$FX"/*.pas 2>/dev/null | ./compiletest

+ 86 - 0
shell/tests/uitest.py

@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""End-to-end UI test for the shell: compile-error jump + Ctrl-K-D quit.
+
+Compiler correctness is covered by matrix/run_compile_tests.sh (fast, no pty).
+This test covers the *shell* behaviour that only exists interactively:
+
+  1. W loads a work file (LoadWorkFile ends with a Pause: needs a filler key)
+  2. C reports a TP3 error (error number + relative text position)
+  3. ESC opens the editor with the cursor ON the error position
+     (TP3 kcwait: waitesc ; BX:=txerrpos ; DEC BX ; JMP editor2)
+  4. Ctrl-K D returns to the main menu
+  5. Q exits the shell cleanly with status 0
+     (a bare HALT aborts with SIGABRT under gm2 -fiso)
+
+Fixture: t02_writeln.pas fails with error 41 at pos 28, which is line 3,
+column 10 (1-based) - the '(' just after 'writeln'.
+
+Usage: uitest.py [fixture.pas]
+"""
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from ptyharness import CTRL_K, SHELL_DIR, Screen, drain, reap, send, spawn, status_str, visible
+
+FIXTURE = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "fixtures", "t02_writeln.pas")
+EXPECT_ERR = "41"
+EXPECT_LINE = 3
+EXPECT_COL = 10
+
+
+def main():
+    pid, fd = spawn()
+    checks = []
+    try:
+        drain(fd, quiet=0.6)
+        send(fd, b"w")
+        send(fd, FIXTURE.encode())
+        send(fd, b"\r", quiet=0.8)
+        send(fd, b" ", quiet=0.5)             # clears LoadWorkFile's Pause
+        comp = visible(send(fd, b"c", quiet=1.5))
+        checks.append(("compile reports a TP3 error", "TP3-style error" in comp))
+        checks.append(("error number is %s (not the 0 from the old "
+                       "self-assignment)" % EXPECT_ERR,
+                       ("error %s " % EXPECT_ERR) in comp))
+        checks.append(("compile did not claim success", "Compiled OK" not in comp))
+
+        ed = send(fd, b"\x1b", quiet=1.0)    # accept the error jump
+        scr = Screen()
+        scr.feed(ed)
+        text = scr.text()
+        row = scr.row_with("writeln")
+        checks.append(("editor opened", "Line %d" % EXPECT_LINE in text))
+        checks.append(("cursor on the faulty line (screen row carries 'writeln')",
+                       row is not None))
+        checks.append(("status line reports Line %d" % EXPECT_LINE,
+                       ("Line %d" % EXPECT_LINE) in text.split("\n")[0]))
+        checks.append(("status line reports Col %d" % EXPECT_COL,
+                       ("Col %d" % EXPECT_COL) in text.split("\n")[0]))
+        checks.append(("cursor is on that row", row is not None and row == scr.r))
+
+        send(fd, CTRL_K, quiet=0.4)
+        after = visible(send(fd, b"d", quiet=0.6))
+        checks.append(("Ctrl-K D returned to the main menu", "Main file" in after))
+        send(fd, b"q", quiet=0.6)
+    finally:
+        status = reap(fd, pid)
+
+    checks.append(("shell exited cleanly (status 0)", os.WIFEXITED(status)
+                   and os.WEXITSTATUS(status) == 0))
+
+    print("UI TEST: %s" % os.path.basename(FIXTURE))
+    print("-" * 60)
+    ok = True
+    for name, passed in checks:
+        print("%-58s %s" % (name, "PASS" if passed else "FAIL"))
+        ok = ok and passed
+    print("-" * 60)
+    print("child: %s" % status_str(status))
+    print("RESULT: %s" % ("ALL PASS" if ok else "FAILURES PRESENT"))
+    return 0 if ok else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())