Просмотр исходного кода

compiler: the {$I file} include (CALC Stage 2)

CALC-COMPILE.md §0: {$I CALC.INC} used to be swallowed as a comment, so
CALC.PAS without its include compiled green.  It is now expanded, and an
include that cannot be honoured is an error, not a comment.

It is a PRE-PASS (ExpandIncludes), not a change to Skip, and that is forced by
a real bug: Skip is called by lookahead routines (DeclaresProc, PeekKw,
KwAhead) that save and restore srcPos, so splicing inside Skip ran once during
the lookahead and again for real - the second pass saw the first splice's text
and reported error 96.  A splice grows the buffer, which a rewind cannot undo.
The pre-pass scans once, before parsing, and recognizes exactly what the lexer
does (a quoted literal, then star-paren comments, then brace comments, then a
brace directive), so {$I} written inside a comment stays inert.

  - Path resolved against the INCLUDING file's directory: Compiler.SetSource
    records the path, called by Shell.LoadWorkFile and the CompileTest/ComTest
    loaders; extension defaults to .PAS (TPSRC10 kpasext).
  - Errors are TP3's: 90 "File not found" (cdincl errb) and 96 "Illegal nesting
    of include files" (cdincl), both measured at the directive.
  - TextBuf.InsertStr is the new bulk, capacity-checked paste; a loop of
    InsertCh would silently truncate at TextLimit.
  - Skip still handles {$I+}/{$I-} as the I/O-check toggle, and now {$R+}/{$R-}
    toggle alongside {$I...} correctly (an earlier cut of this change only
    tested the letter I in the signed branch and would have dropped R).

Fixtures, all proved red first by forcing the include off (t56 -> ERR 41,
t57/t58 -> OK):
  t56_include          OK 56 4   and its .out is `7', the INCLUDED procedure
                                 actually running (qemu and Exec86)
  t57_include_missing  ERR 90 29
  t58_include_nest     ERR 96 43 (t58_include_a.inc holds the nested {$I})
nonvacuity.sh gains the matching case: `isInc := TRUE` -> FALSE turns t56 red.

The ladder does not move: CALC.PAS's include is at normalised 9404 and the
compiler still stops at 7791 (the typed constant, Stage 4), so it never reaches
the include yet.  The row stays CALC.PAS ERR 1 7791, pinned now by t56/t57/t58.

run_all.sh: ALL PASS (compile matrix 59/59, 49 linked, 49 executed under qemu
and Exec86).  nonvacuity.sh: 79 ok, 0 failed.
Eric Streit 13 часов назад
Родитель
Сommit
7eec556592

+ 70 - 32
CALC-COMPILE.md

@@ -26,19 +26,21 @@ Everything below is measured against the tree at tag `v-TP3-INTERRUPT-RESTORE`
 2. **Compile first, run second.** The gate is the compiler's verdict, not a
    working spreadsheet. A construct may be *incomplete* only if it is *loudly*
    incomplete (`Err (ENoLib)`); it may never be accepted and mis-compiled.
-   This project has already shipped that bug once and it is currently shipping
-   it twice:
+   This project has already shipped that bug once. One of the two cases below
+   is now closed (Stage 2); the other is not:
 
    - `var` parameters are **silently accepted and passed by value** —
      `ProcFunc` drops the `var` (`DropB (MatchKey (tok))`, comment "VAR
      parameter recorded as value in this milestone"); `writeln (g)` after
      `q (g)` prints `7`, not `42`. Ten CALC routines take `var` parameters.
-   - `{$I CALC.INC}` is **silently swallowed as a comment**, so `CALC.PAS`
-     without its include compiles green. Measured:
-     `program p; {$I part.inc} begin halt end.` → `OK code=32 data=4`, with
-     `part.inc` never opened.
+   - `{$I CALC.INC}` was **silently swallowed as a comment**, so `CALC.PAS`
+     without its include compiled green (`program p; {$I part.inc} begin halt
+     end.` → `OK code=32 data=4`, `part.inc` never opened). **Closed at Stage
+     2**: the include is expanded before parsing and an unopenable file is
+     error 90, not a comment.
 
-   Both must be closed by implementation, and until then by an explicit `Err`.
+   The `var`-parameter case is still open and must be closed by
+   implementation, or by an explicit `Err` until it is (Stage 10).
 
 3. **One fixture and one ratchet per step.** Every stage lands with a fixture
    that is provably red before and green after, plus a move of the ratchet
@@ -98,6 +100,11 @@ Current rungs (measured today, normalised positions):
 | 8060 | `SetOfAttri = Set of Attributes` (`ERR 102`, `ENoLib`) |
 | 8237 | `CellRec = record` (`ERR 102`, `ENoLib`) |
 
+The include is at normalised `9404`, **after** the current stop, so Stage 2
+does not move the row (it is pinned by t56/t57/t58 instead); the first rung
+that moves it is Stage 4, which removes the 7791 blocker and lets the compiler
+reach the include and everything behind it.
+
 ---
 
 ## 2. The census — what CALC actually needs
@@ -109,7 +116,7 @@ stripped.
 
 | construct | where | status today |
 |---|---|---|
-| `{$I CALC.INC}` | `CALC.PAS:150` | **swallowed silently** |
+| `{$I CALC.INC}` | `CALC.PAS:150` | **done (Stage 2)** — expanded before parsing; `t56`/`t57`/`t58` |
 | typed constants (`X : T = v`) | `CALC.PAS:111,113,114,137`; `CALC.INC:777` | `ERR 1` |
 | enum type `(Constant,Formula,…)` | `CALC.PAS:120` | `ERR 41` |
 | subrange types `'A'..'G'`, `0..20`, `string[70]` | `CALC.PAS:117,119,130` | OK |
@@ -349,17 +356,26 @@ ladder order (each stage removes the blocker the previous one exposed).
 
 ### Session state — where to resume
 
-Last touched 2026-10-19. **No compiler change has been made yet** — Stage 1 is
-the measuring device, not a compiler feature, so the front end is still exactly
-the *pre*-CALC baseline that every later stage must build on.
+Last touched 2026-10-19. **The front end now has its first CALC feature** — the
+`{$I}` include (Stage 2) — and the measuring device (Stage 1) under it.
 
-- **HEAD** `ce7e8cd` (this document) plus the Stage 1 commit. Nothing pushed;
-  `origin/master` is behind.
+- **HEAD** `ce7e8cd` (the plan) plus the Stage 1 and Stage 2 commits. Nothing
+  pushed; `origin/master` is behind.
 - **Stage 1 (the ratchet) is landed:** `tests/fixtures/calc_ladder.tsv` +
   `tests/calc_ladder.sh`, wired into `run_all.sh` as `CALC ladder`. The row is
   `CALC.PAS  ERR  1  7791`, and it was **proved red first** (row set to 7792 →
   `FAIL expected ERR 1 7792 / actual ERR 1 7791`, rc 1) before the real value
-  was written back. The compile matrix is still **56/56**.
+  was written back.
+- **Stage 2 (`{$I file}`) is landed.** `ExpandIncludes` resolves includes as a
+  pre-pass (not in `Skip`, because `DeclaresProc`'s lookahead rewinds `srcPos`),
+  `Compiler.SetSource` gives the compiler the source path so an include resolves
+  beside its including file, and the errors are TP3's own: 90 file-not-found, 96
+  illegal nesting. Fixtures `t56_include` (OK, and its `.out` proves the
+  included procedure runs), `t57_include_missing` (ERR 90),
+  `t58_include_nest` (ERR 96). `TextBuf.InsertStr` is the new bulk,
+  capacity-checked paste. **The ladder row does not move** — the include is at
+  normalised 9404 and the compiler still stops at 7791 — which is expected and
+  pinned by the three fixtures instead.
 - **One surprise on resuming:** the working tree was found holding the
   nonvacuity script's *frame-rule* mutation of `shell/Compiler.mod` (the
   `locFree := 0FFFEH` / pre-decrement-base revert), i.e. a mutation from a
@@ -369,8 +385,8 @@ the *pre*-CALC baseline that every later stage must build on.
   byte-for-byte the same for INTEGER locals, so the compile matrix would have
   been green and only `t40_readln` (and later CALC) would have noticed.
 - **Verified baseline, run on this commit:** `./tests/run_all.sh` →
-  **OVERALL: ALL PASS**, compile matrix **56/56**, CALC ladder **1/1**. Those are
-  the numbers Stage 1's ratchet must leave intact.
+  **OVERALL: ALL PASS**, compile matrix **59/59**, CALC ladder **1/1**, 49 `.COM`
+  linked and 49 executed under both qemu and Exec86.
 - **Not re-run this session:** `tests/nonvacuity.sh` (78 ok / 0 failed at the
   last run). It is not in the default `run_all.sh` and must be run **manually**
   after touching any check — including, now, `run_compile_tests.sh`'s
@@ -413,22 +429,44 @@ it was set to 7791. It also resolved Q4 in favour of staging copies in
 `tmp/calc_ladder/` rather than teaching the compile matrix about companion
 files.
 
-**Stage 2 — `{$I file}` include.** Small, self-contained, and a prerequisite:
-`CALC.PAS:150` *is* the program.
-- In `Skip` (`Compiler.mod:1315`), the `{$` path currently reads a letter and a
-  sign and discards to `}`. Split it: `{$I+}` / `{$I-}` stay the I/O-check
-  toggle (`dirs.chk`, used at `CALC.INC:396,399`); `{$I <name>}` reads a file
-  name and splices that file's text into the buffer at `srcPos`.
-- `{$A-}` (`CALC.INC:712`) is not a TP3 directive; being ignored is correct.
-  But *unknown-but-evaluated* directives must not be the same code path as
-  *include*: a directive we cannot honour is an `Err`, not a comment.
-- Design points: path resolved relative to the including file; a nesting cap;
-  an unopenable file is an error; the splice grows `srcLen` and leaves `srcPos`
-  moving forward so `pos` stays meaningful (see the ratchet trap in §1).
-- Fixture: a pair (`*.pas` + a companion `.inc`) — **check first that
-  `run_compile_tests.sh` can stage a companion file**, which is an open
-  question (§6 Q4). Prove red by the current behaviour:
-  `program p; {$I part.inc} begin halt end.` compiles OK today.
+**Stage 2 — `{$I file}` include. DONE.**
+
+Implemented as a **pre-pass, not in `Skip`**, and that is the one design point
+worth recording. `Skip` is called by lookahead routines (`DeclaresProc`,
+`PeekKw`, `KwAhead`) that save and restore `srcPos`; splicing inside `Skip`
+runs once during the lookahead and again for real, and the second pass sees the
+first splice's text and reports error 96 (and a splice grows the buffer, which
+a rewind cannot undo). So `ExpandIncludes` resolves every include in one
+forward scan *before parsing*, and the parser — including `DeclaresProc`'s scan
+for a `PROCEDURE` keyword, which must see a procedure that lives only in the
+include — sees one finished buffer. The scan recognises what the lexer does (a
+quoted literal, then star-paren comments, then brace comments, then a brace
+directive), so `{$I}` written inside a comment stays inert (t56 depends on it).
+
+- `{$I <name>}` is spliced at the directive (buffer grows, `srcLen` updated) in
+  the **expanded-buffer** coordinates the ladder already measures in; `{$I+}` /
+  `{$I-}` remain the I/O-check toggle (`dirs.chk`, `CALC.INC:396,399`).
+- Path resolved against the **including file's** directory — the compiler now
+  gets the source path through `Compiler.SetSource`, called by the three
+  loaders. Extension defaults to `.PAS` when absent (TPSRC10 `kpasext`).
+- An unopenable file is **error 90** ("File not found", TPSRC10 `cdincl` `errb`)
+  at the directive; a nested include is **error 96** ("Illegal nesting of
+  include files", TPSRC10 `cdincl`). Both measured, not assumed.
+- `{$A-}` is in fact a real TP3 directive (TPSRC10:783, Absolute code) that TP3
+  accepts and this port ignores; the "not a TP3 directive" note was wrong. The
+  rule that mattered is kept: an include can never fall through to the comment
+  path.
+- Fixtures: `t56_include` (+`t56_include.inc`, red 41 → green OK, and its
+  `.out` proves the included procedure *runs*), `t57_include_missing`
+  (red OK → `ERR 90`), `t58_include_nest` (+`t58_include_a.inc`, red OK →
+  `ERR 96`). Q4 resolved: the compile matrix needs no companion-file notion,
+  because the `.inc` simply sits beside the `.pas` in `tests/fixtures/` and the
+  include resolves there.
+- **The ladder does not move at this stage.** `CALC.PAS`'s include is at
+  normalised `9404`, and the compiler stops at `7791` — the typed constant of
+  Stage 4 — so the include is never reached for `CALC.PAS` yet. Stage 2 is
+  pinned by t56/t57/t58 until Stage 4 lets the compiler get to it; the rung
+  moving is a Stage 4 event.
 
 **Stage 3 — lexical `#nn` and `^X` character constants.** Small; both are `ERR`
 today. `#27` (decimal) and `#255`, `$FF` (hex works already), `^M`/`^E`/`^[`.

+ 6 - 4
SUMMARY.md

@@ -2036,10 +2036,12 @@ reals → string builtins → files → CRT → recursion → layout), plus the
 census of what `CALC.PAS` actually needs and the reference measurements from
 the original `TURBO.COM`. Two tracks run in parallel: the ladder (front-end,
 verifiable on small fixtures) and the layout (§3, the only thing that stops
-the compiler *emitting* a 16,560-byte program). Stage 1, the ratchet, is landed
-(`tests/calc_ladder.sh` + `tests/fixtures/calc_ladder.tsv`, run by
-`run_all.sh`; CALC.PAS currently stops at `ERR 1` at pos 7791). Items 1-4 below
-are the string/robustness work and are independent of both.
+the compiler *emitting* a 16,560-byte program). Stages 1-2 are landed: the
+ratchet (`tests/calc_ladder.sh` + `tests/fixtures/calc_ladder.tsv`, run by
+`run_all.sh`; CALC.PAS currently stops at `ERR 1` at pos 7791) and the `{$I}`
+include (`ExpandIncludes`, resolved before parsing; fixtures `t56_include`,
+`t57_include_missing`, `t58_include_nest`). Items 1-4 below are the
+string/robustness work and are independent of both.
 
 1. The rest of the string language. `v-TP3-STRVAR`/`v-TP3-STRREAD-ASSIGN` cover
    string variables, `s := 'literal'`, `writeln (s)`, `readln (s)`/`read (s)`

+ 7 - 0
shell/Compiler.def

@@ -19,6 +19,13 @@ PROCEDURE Compile (VAR errNo, errPos : CARDINAL) : BOOLEAN ;
    returns FALSE, errNo carries the TP3-style error number and errPos
    the relative source offset (0-based) for the editor. *)
 
+PROCEDURE SetSource (path : ARRAY OF CHAR) ;
+(* Name the file the current TextBuf was loaded from.  Only its directory is
+   used: a {$I file} directive resolves the name against the including file's
+   directory, so the compiler has to know where that file came from.  The
+   loaders (Shell.LoadWorkFile, CompileTest/ComTest LoadFile) call this; an
+   unset name means includes resolve against the current directory. *)
+
 PROCEDURE CodeBytes () : CARDINAL ;
 (* emitted code size in bytes *)
 

+ 255 - 15
shell/Compiler.mod

@@ -43,9 +43,11 @@ IMPLEMENTATION MODULE Compiler ;
    by the runtime's size, and CmdRun (the interpreter) are still pending,
    so a compiled image cannot be executed yet. *)
 
-FROM TextBuf IMPORT Length, CharAt ;
+FROM TextBuf IMPORT TextLimit, Length, CharAt, InsertStr, DeleteFromTo ;
 
-FROM SYSTEM IMPORT BYTE ;
+FROM SYSTEM IMPORT BYTE, ADR ;
+
+FROM Posix IMPORT open, read, close ;
 
 FROM Runtime IMPORT RT_Build, RT_Size, RT_Byte, RT_Entry, LoadBias ;
 (* The runtime is copied to the front of the code buffer and pc/dc start past
@@ -133,6 +135,8 @@ CONST
    EUnknown   = 41 ; EConstRange = 45 ; EMemOvf    = 98 ;
    ECompOvf   = 99 ; ENoLib     = 102 ; ETypeErr   = 56 ;
    EIoHere    = 66 ;            (* I/O not allowed here - TPSRC8 prderrtp *)
+   EFileNF    = 90 ;            (* File not found - TPSRC10 cdincl, errb *)
+   EInclNest  = 96 ;            (* Illegal nesting of include files - TPSRC10 cdincl *)
 
    AposC      = 39 ;            (* ORD ("'") - avoids quote-in-quote *)
 
@@ -195,6 +199,20 @@ TYPE
 VAR
    srcPos, srcLen : CARDINAL ;
 
+   (* Include state.  srcName is the path the buffer was loaded from, set by
+      SetSource; only its directory is used, because a {$I file} directive
+      names the file relative to the INCLUDING file.  Includes are expanded by
+      ExpandIncludes BEFORE parsing, so inclEnd is the end of the include
+      region currently being scanned and inclActive says one is open - a
+      nested {$I} inside that region is error 96, since TP3 permits only one
+      level.  incBuf holds one whole include file while it is spliced. *)
+   srcName    : ARRAY [0..255] OF CHAR ;
+   inclName   : ARRAY [0..127] OF CHAR ;
+   inclPath   : ARRAY [0..383] OF CHAR ;
+   inclEnd    : CARDINAL ;
+   inclActive : BOOLEAN ;
+   incBuf     : ARRAY [0..TextLimit] OF CHAR ;
+
    wrd  : ARRAY [0..MaxName] OF CHAR ;
 
    (* String-literal pool.
@@ -431,6 +449,214 @@ BEGIN
    RETURN NOT abortFac
 END OK ;
 
+(* ---------------------------------------------------------------- *)
+(*  {$I file} include                                               *)
+(* ---------------------------------------------------------------- *)
+
+PROCEDURE SetSource (path : ARRAY OF CHAR) ;
+(* remember the path the buffer was loaded from; MakeIncludePath keeps only its
+   directory *)
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE (i < 255) AND (i <= HIGH (path)) AND (path [i] # 0C) DO
+      srcName [i] := path [i] ;
+      INC (i)
+   END ;
+   srcName [i] := 0C
+END SetSource ;
+
+PROCEDURE ReadWholeFile (path : ARRAY OF CHAR ; VAR n : CARDINAL) : BOOLEAN ;
+(* Read path into incBuf with the same CR/LF normalisation as LoadWorkFile,
+   and report its length in n.  FALSE if it could not be opened.  ^Z ends the
+   text, as it does for every other reader here. *)
+VAR fd : INTEGER ;
+    k : LONGINT ;
+    b : BYTE ;
+    ch : CHAR ;
+    prevCR, emit : BOOLEAN ;
+BEGIN
+   n := 0 ;
+   fd := open (ADR (path), 0, 0) ;
+   IF fd < 0 THEN
+      RETURN FALSE
+   END ;
+   prevCR := FALSE ;
+   LOOP
+      k := read (fd, ADR (b), 1) ;
+      IF k # 1 THEN EXIT END ;
+      IF b = 26 THEN EXIT END ;            (* ^Z *)
+      emit := TRUE ;
+      IF b = 10 THEN
+         IF prevCR THEN
+            emit := FALSE
+         ELSE
+            ch := CHR (13)                 (* lone LF -> CR *)
+         END ;
+         prevCR := FALSE
+      ELSIF b = 13 THEN
+         ch := CHR (13) ;
+         prevCR := TRUE
+      ELSE
+         ch := CHR (ORD (b)) ;
+         prevCR := FALSE
+      END ;
+      IF emit AND (n <= TextLimit) THEN
+         incBuf [n] := ch ;
+         INC (n)
+      END
+   END ;
+   k := close (fd) ;
+   RETURN TRUE
+END ReadWholeFile ;
+
+PROCEDURE MakeIncludePath () ;
+(* inclPath := directory (srcName) + inclName, defaulting the extension to
+   .PAS when the name has none (TPSRC10 kpasext). *)
+VAR i, j, last : CARDINAL ; dot : BOOLEAN ;
+BEGIN
+   dot := FALSE ;
+   i := 0 ;
+   WHILE (i < 127) AND (inclName [i] # 0C) DO
+      IF inclName [i] = '.' THEN dot := TRUE END ;
+      INC (i)
+   END ;
+   IF (NOT dot) AND (i <= 123) THEN
+      inclName [i]     := '.' ;
+      inclName [i + 1] := 'P' ;
+      inclName [i + 2] := 'A' ;
+      inclName [i + 3] := 'S' ;
+      inclName [i + 4] := 0C
+   END ;
+   last := 0 ;
+   i := 0 ;
+   WHILE (i < 256) AND (srcName [i] # 0C) DO
+      IF srcName [i] = '/' THEN last := i + 1 END ;
+      INC (i)
+   END ;
+   j := 0 ;
+   WHILE j < last DO
+      inclPath [j] := srcName [j] ;
+      INC (j)
+   END ;
+   i := 0 ;
+   WHILE (i < 127) AND (inclName [i] # 0C) DO
+      inclPath [j] := inclName [i] ;
+      INC (j) ;
+      INC (i)
+   END ;
+   inclPath [j] := 0C
+END MakeIncludePath ;
+
+PROCEDURE ExpandIncludes () ;
+(* Replace every {$I name} directive in the buffer with the named file's text,
+   BEFORE parsing.
+
+   This is a pre-pass, and it has to be.  Skip is called by lookahead routines
+   (DeclaresProc, PeekKw, KwAhead) that SAVE AND RESTORE srcPos; an include
+   performed inside Skip would run once during the lookahead and once for real,
+   and the second pass would see the first splice's text and report error 96.
+   A splice also grows the buffer, which a rewind cannot undo.  So the whole
+   expansion is done here, in one forward scan with no lookahead, and the
+   parser only ever sees the finished text.
+
+   The scan has to recognise exactly what the lexer does, or it would expand a
+   directive that the lexer would have read as a string or a comment: a quoted
+   literal is skipped, then star-paren comments (which is why {$I} written
+   inside one is inert - t56_include.inc relies on that), then brace comments,
+   and only then a brace directive.
+
+   Divergence from the original: TP3 STREAMS the include file line by line and
+   switches back to the outer file at EOF (TPSRC10 fileline/getincl/fileeof);
+   we hold the whole source in TextBuf and address it by index, so the include
+   is spliced in place.  A nested {$I} therefore cannot switch back on its own
+   - which TP3 also refuses, and that is the error 96 reported here. *)
+VAR i, d0, d1, k, ni, n : CARDINAL ;
+    c, letter : CHAR ;
+    isInc : BOOLEAN ;
+BEGIN
+   i := 0 ;
+   inclActive := FALSE ;
+   inclEnd := 0 ;
+   WHILE (i < Length ()) AND OK () DO
+      c := CharAt (i) ;
+      IF c = "'" THEN
+         INC (i) ;
+         WHILE (i < Length ()) AND (CharAt (i) # "'") DO INC (i) END ;
+         IF i < Length () THEN INC (i) END
+      ELSIF (c = '(') AND (i + 1 < Length ()) AND (CharAt (i + 1) = '*') THEN
+         INC (i, 2) ;
+         WHILE (i < Length ()) AND NOT ((CharAt (i) = '*')
+                     AND (i + 1 < Length ()) AND (CharAt (i + 1) = ')')) DO
+            INC (i)
+         END ;
+         IF i < Length () THEN INC (i, 2) END
+      ELSIF (c = '{') AND (i + 1 < Length ()) AND (CharAt (i + 1) = '$') THEN
+         d0 := i ;
+         d1 := i + 2 ;
+         WHILE (d1 < Length ()) AND (CharAt (d1) # '}') DO INC (d1) END ;
+         IF i + 2 < Length () THEN
+            letter := Upper (CharAt (i + 2))
+         ELSE
+            letter := 0C
+         END ;
+         isInc := FALSE ;
+         IF letter = 'I' THEN
+            k := i + 3 ;
+            WHILE (k < d1) AND ((CharAt (k) = ' ') OR (CharAt (k) = CHR (9))) DO
+               INC (k)
+            END ;
+            IF (k >= d1) OR ((CharAt (k) # '+') AND (CharAt (k) # '-')) THEN
+               isInc := TRUE
+            END
+         END ;
+         IF isInc THEN
+            IF inclActive AND (d0 < inclEnd) THEN
+               srcPos := d0 ;
+               Err (EInclNest) ;                  (* 96 *)
+               RETURN
+            END ;
+            ni := 0 ;
+            WHILE (k < d1) AND (ni < 127) DO
+               inclName [ni] := CharAt (k) ;
+               INC (k) ;
+               INC (ni)
+            END ;
+            WHILE (ni > 0) AND ((inclName [ni - 1] = ' ')
+                                OR (inclName [ni - 1] = CHR (9))) DO
+               DEC (ni)
+            END ;
+            inclName [ni] := 0C ;
+            MakeIncludePath () ;
+            IF NOT ReadWholeFile (inclPath, n) THEN
+               srcPos := d0 ;
+               Err (EFileNF) ;                    (* 90 *)
+               RETURN
+            END ;
+            DeleteFromTo (d0, d1 - d0 + 1) ;
+            IF NOT InsertStr (d0, incBuf, n) THEN
+               srcPos := d0 ;
+               Err (EMemOvf) ;
+               RETURN
+            END ;
+            inclEnd := d0 + n ;
+            inclActive := TRUE ;
+            i := d0                                 (* scan the new text too *)
+         ELSE
+            i := d1 + 1                             (* ordinary directive: leave it *)
+         END
+      ELSIF c = '{' THEN
+         INC (i) ;
+         WHILE (i < Length ()) AND (CharAt (i) # '}') DO INC (i) END ;
+         IF i < Length () THEN INC (i) END
+      ELSE
+         INC (i)
+      END
+   END ;
+   srcLen := Length () ;
+   srcPos := 0
+END ExpandIncludes ;
+
 (* ---------------------------------------------------------------- *)
 (*  emission :  ebyte / eword / ecall / ejump                        *)
 (* ---------------------------------------------------------------- *)
@@ -1313,7 +1539,10 @@ END IsBlank ;
 
 PROCEDURE Skip () ;
 (* blanks, comments { } and (* *), compiler directives {$ } / (*$ *)
-   letter + sign toggles rng/chk *)
+   letter + sign toggles rng/chk.  Include directives are already gone by the
+   time Skip runs: ExpandIncludes splices them into the buffer first, so the
+   {$I file} spelling never reaches here.  {$I+}/{$I-} still do, and stay the
+   I/O-check toggle, exactly as TPSRC10 cdirec splits them on the sign. *)
 VAR ch : CHAR ;
     letter : CHAR ;
 BEGIN
@@ -1325,14 +1554,15 @@ BEGIN
          ch := GetCh () ;
          IF CurCh () = '$' THEN
             ch := GetCh () ;
-            letter := GetCh () ;
-            ch := GetCh () ;
-            IF ch = '+' THEN
-               IF letter = 'R' THEN dirs.rng := TRUE END ;
-               IF letter = 'I' THEN dirs.chk := TRUE END
-            ELSIF ch = '-' THEN
-               IF letter = 'R' THEN dirs.rng := FALSE END ;
-               IF letter = 'I' THEN dirs.chk := FALSE END
+            letter := Upper (GetCh ()) ;
+            IF CurCh () = '+' THEN
+               ch := GetCh () ;
+               IF letter = 'I' THEN dirs.chk := TRUE END ;
+               IF letter = 'R' THEN dirs.rng := TRUE END
+            ELSIF CurCh () = '-' THEN
+               ch := GetCh () ;
+               IF letter = 'I' THEN dirs.chk := FALSE END ;
+               IF letter = 'R' THEN dirs.rng := FALSE END
             END
          END ;
          WHILE (CurCh () # '}') AND (CurCh () # 0C) DO
@@ -1346,11 +1576,14 @@ BEGIN
          ch := GetCh () ;
          IF CurCh () = '$' THEN
             ch := GetCh () ;
-            letter := GetCh () ;
-            ch := GetCh () ;
-            IF ch = '+' THEN
+            letter := Upper (GetCh ()) ;
+            IF CurCh () = '+' THEN
+               ch := GetCh () ;
+               IF letter = 'I' THEN dirs.chk := TRUE END ;
                IF letter = 'R' THEN dirs.rng := TRUE END
-            ELSIF ch = '-' THEN
+            ELSIF CurCh () = '-' THEN
+               ch := GetCh () ;
+               IF letter = 'I' THEN dirs.chk := FALSE END ;
                IF letter = 'R' THEN dirs.rng := FALSE END
             END
          END ;
@@ -4043,6 +4276,8 @@ BEGIN
    txerrPos := 0 ;
    srcPos := 0 ;
    srcLen := Length () ;
+   inclActive := FALSE ;
+   inclEnd := 0 ;
    (* The image layout is
 
           [JMP rel16][runtime][program header][program code]
@@ -4187,6 +4422,11 @@ VAR tok : CARDINAL ;
     hasProc : BOOLEAN ;
 BEGIN
    Inittur () ;
+   IF OK () THEN
+      (* Resolve every {$I} first: the parser (and DeclaresProc's lookahead)
+         must see one finished buffer.  See ExpandIncludes. *)
+      ExpandIncludes ()
+   END ;
    IF OK () THEN
       (* program prologue: header words, CALL initmem, MOV BP,SP *)
       HeadWord (hdrFlag) ;

+ 3 - 1
shell/Shell.mod

@@ -15,7 +15,8 @@ FROM Posix IMPORT
    getcwd, chdir, opendir, readdir, closedir, statvfs,
    Dir, dirent, statvfsbuf ;
 
-FROM Compiler IMPORT Compile, CodeBytes, DataBytes, ImageBytes, ImageByteAt ;
+FROM Compiler IMPORT Compile, CodeBytes, DataBytes, ImageBytes, ImageByteAt,
+                       SetSource ;
 FROM Linker IMPORT LinkSize, WriteCom ;
 FROM Editor IMPORT Run, GotoOffset ;
 
@@ -374,6 +375,7 @@ BEGIN
    StrClear (path) ;
    StrAppend (path, workName) ;
    fd := open (ADR (path), O_RDONLY, 0) ;
+   SetSource (path) ;                  (* {$I} resolves beside this file *)
    IF fd < 0 THEN
       PutStr ("New File") ;
       CrLf ;

+ 7 - 0
shell/TextBuf.def

@@ -20,6 +20,13 @@ PROCEDURE InsertCh (i : CARDINAL ; ch : CHAR) ;
 (* insert one character at offset i, shifting text right.
    Does nothing if the buffer is full. *)
 
+PROCEDURE InsertStr (i : CARDINAL ; VAR s : ARRAY OF CHAR ; n : CARDINAL) : BOOLEAN ;
+(* insert the first n characters of s at offset i, in one right-shift.
+   Returns FALSE, and changes nothing, if they do not all fit - so a caller
+   pasting a whole file in cannot get a silently truncated buffer the way a
+   loop of InsertCh would (InsertCh drops the overflow character and says
+   nothing). *)
+
 PROCEDURE OverwriteCh (i : CARDINAL ; ch : CHAR) ;
 (* replace the character at offset i (no insertion) *)
 

+ 24 - 0
shell/TextBuf.mod

@@ -40,6 +40,30 @@ BEGIN
    INC (used)
 END InsertCh ;
 
+PROCEDURE InsertStr (i : CARDINAL ; VAR s : ARRAY OF CHAR ; n : CARDINAL) : BOOLEAN ;
+VAR j : CARDINAL ;
+BEGIN
+   IF used + n > TextLimit THEN
+      RETURN FALSE
+   END ;
+   IF i > used THEN
+      i := used
+   END ;
+   (* move the tail right by n, from the back so nothing is overwritten *)
+   j := used ;
+   WHILE j > i DO
+      buf [j - 1 + n] := buf [j - 1] ;
+      DEC (j)
+   END ;
+   j := 0 ;
+   WHILE j < n DO
+      buf [i + j] := s [j] ;
+      INC (j)
+   END ;
+   INC (used, n) ;
+   RETURN TRUE
+END InsertStr ;
+
 PROCEDURE OverwriteCh (i : CARDINAL ; ch : CHAR) ;
 BEGIN
    IF i < used THEN

+ 2 - 1
shell/tests/ComTest.mod

@@ -30,7 +30,7 @@ MODULE ComTest ;
 
 FROM Posix IMPORT read, write, open, close ;
 FROM TextBuf IMPORT TextLimit, Clear, Length, CharAt, InsertCh ;
-FROM Compiler IMPORT Compile, CodeBytes, DataBytes, ImageBytes, ImageByteAt, DataBase ;
+FROM Compiler IMPORT Compile, CodeBytes, DataBytes, ImageBytes, ImageByteAt, DataBase, SetSource ;
 FROM Linker IMPORT LinkSize, WriteCom ;
 FROM SYSTEM IMPORT ADR, BYTE ;
 
@@ -189,6 +189,7 @@ BEGIN
    IF fd < 0 THEN
       RETURN FALSE
    END ;
+   SetSource (z) ;                     (* {$I} resolves beside this file *)
    Clear ;
    prevCR := FALSE ;
    LOOP

+ 2 - 1
shell/tests/CompileTest.mod

@@ -16,7 +16,7 @@ MODULE CompileTest ;
 FROM Posix IMPORT read, write, open, close ;
 FROM TextBuf IMPORT TextLimit, Clear, Length, CharAt, InsertCh ;
 FROM Compiler IMPORT Compile, CodeBytes, DataBytes, CodeByteAt,
-                       ImageBytes, ImageByteAt, DataBase ;
+                       ImageBytes, ImageByteAt, DataBase, SetSource ;
 FROM SYSTEM IMPORT ADR, BYTE ;
 
 CONST
@@ -121,6 +121,7 @@ BEGIN
    IF fd < 0 THEN
       RETURN FALSE
    END ;
+   SetSource (p) ;                     (* {$I} resolves beside this file *)
    Clear ;
    prevCR := FALSE ;
    LOOP

+ 22 - 0
shell/tests/fixtures/expected.tsv

@@ -418,3 +418,25 @@ t54_strcat_strnum	ERR	56	463
 #                   data=516 is s (the default string = 256), one scratch
 #                   (256), and the two @@T temporaries (4).
 t55_strcat_overflow	OK	126	516
+#   t56_include            {$I t56_include.inc} - the include is expanded
+#                   before parsing (ExpandIncludes), so FromInclude, which
+#                   exists only in the .inc, is a real symbol here.  The .inc
+#                   also carries a star-paren comment holding the text {$I} on
+#                   purpose: the scanner has to read that as comment text, not
+#                   as a directive, or the program breaks.  code=56 data=4 (the
+#                   two @@T temporaries); the .out is `7', which is the
+#                   INCLUDED procedure actually running, so this is not just
+#                   "the name resolved".
+t56_include	OK	56	4
+#   t57_include_missing    {$I t57_this_file_does_not_exist.inc}: an include
+#                   that cannot be opened is TP3 error 90 ("File not found",
+#                   TPSRC10 cdincl errb) AT THE DIRECTIVE - not a silent
+#                   swallow, which is exactly the bug this stage closes.
+t57_include_missing	ERR	90	29
+#   t58_include_nest       t58_include_a.inc contains a second {$I}.  TP3
+#                   permits only one level of include (error 96, "Illegal
+#                   nesting of include files", TPSRC10 cdincl), so the nested
+#                   directive is refused rather than expanded.  The position is
+#                   inside the spliced text, i.e. in expanded-buffer
+#                   coordinates.
+t58_include_nest	ERR	96	43

+ 7 - 0
shell/tests/fixtures/t56_include.inc

@@ -0,0 +1,7 @@
+(* included by t56_include.pas.  Its whole point is that it is NOT in the
+   program's own text: if {$I} is swallowed as a comment, FromInclude is an
+   unknown identifier and the program does not compile. *)
+procedure FromInclude ;
+begin
+   writeln (7)
+end ;

+ 1 - 0
shell/tests/fixtures/t56_include.out

@@ -0,0 +1 @@
+7

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

@@ -0,0 +1,5 @@
+program t56_include;
+{$I t56_include.inc}
+begin
+   FromInclude
+end.

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

@@ -0,0 +1,4 @@
+program t57_include_missing;
+{$I t57_this_file_does_not_exist.inc}
+begin
+end.

+ 3 - 0
shell/tests/fixtures/t58_include_a.inc

@@ -0,0 +1,3 @@
+const
+   A = 1 ;
+{$I t58_include_b.inc}

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

@@ -0,0 +1,4 @@
+program t58_include_nest;
+{$I t58_include_a.inc}
+begin
+end.

+ 29 - 0
shell/tests/nonvacuity.sh

@@ -1424,6 +1424,35 @@ else
 fi
 cp "$SAVED_C" Compiler.mod
 
+# --- the include pre-pass -------------------------------------------------
+# ExpandIncludes is the only thing that turns {$I name} into the named file;
+# forced off, it is exactly the old bug - the directive is a comment and the
+# include's own symbols do not exist.  t56_include's `FromInclude' lives only in
+# the .inc, so the compile matrix demands OK 56 4 and gets ERR 41 instead.  That
+# pins the fixture to the include ACTUALLY happening, not merely to the buffer
+# being well formed; t57/t58 pin the two refusals (90 and 96) separately.
+cp "$SAVED_C" Compiler.mod
+if python3 - <<'PYX'
+p = 'Compiler.mod'
+s = open(p).read()
+old = 'isInc := TRUE'
+assert s.count(old) == 1, 'the include flag found %d times -- update this mutation' % s.count(old)
+open(p, 'w').write(s.replace(old, 'isInc := FALSE'))
+PYX
+then
+    if rebuild_compiler; then
+        expect_red "t56 red when the include is swallowed, not expanded" \
+            "expected: OK 56 4" tests/run_compile_tests.sh
+    else
+        echo "  FAIL: the compiler would not rebuild with the include forced off"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the include-flag mutation did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_C" Compiler.mod
+
 if rebuild_compiler; then
     if python3 tests/run_com_exec.py >/dev/null 2>&1; then
         echo "  ok: every executed fixture passes on the restored sources"