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

feat: add separate compilation with per-unit objects

Eric Streit 9 часов назад
Родитель
Сommit
0adc637908

+ 2 - 0
.gitignore

@@ -9,3 +9,5 @@ tools/topspeed-grammar/build/
 # gap reproducers: listings
 docs/gaps/*.LST
 *.tmp
+gen_ssa/
+m2make.log

+ 7 - 5
README.md

@@ -48,13 +48,13 @@ docs/              STATUS.md, plan.md, plan-*.md, analysis_*.md, summary_*.md
 
 ## Status (2026-10-23)
 
-- Suite **301/301**; self-hosting **fixpoint OK** (byte-identical
-  stage2/stage3, 3,573,487 bytes); corpus **519/612 compile-OK,
+- Suite **303/303**; self-hosting **fixpoint OK** (byte-identical
+  stage2/stage3, 3,717,887 bytes); corpus **519/612 compile-OK,
   0 crashes** (`tools/v3-corpus`; the corpus is gm2's testsuite, so it
   mixes conforming Modula-2 with gm2 leniencies — see `docs/gaps/`).
   The remaining `except*`/`onebyte*`-style corpus files still hit V3's
   declaration-order policy.
-- Active branch **`master`**, feature state **`v3-open-arrays`**.
+- Active branch **`master`**, feature state **`v3-separate-compilation`**.
   Recent milestones: `PACKEDSET`, ISO exceptions (`EXCEPT`/`RETRY`,
   `EXCEPTIONS`, `M2EXCEPTION`, catchable language traps including
   `functionException`, module `FINALLY`), self-referential pointer
@@ -63,7 +63,8 @@ docs/              STATUS.md, plan.md, plan-*.md, analysis_*.md, summary_*.md
   payload `SIZE`/view `ADR`, flat multi-dimensional record fields and
   constructors, pointer-based 1-D open views, an `LFLOAT` builtin, and
   dialect library folders (`lib/pim`, `lib/iso`, `lib/common`,
-  `lib/gm2`).
+  `lib/gm2`), and **separate compilation** (`M2 -c` objects,
+  `M2make --separate`).
 
 Lowers today: program modules; `CONST`/`VAR`/`TYPE` (scalars,
 subranges, enums, arrays, records incl. variants and self-referential
@@ -81,7 +82,8 @@ Clarion-form `CLASS` with methods, inheritance and virtual dispatch;
 PIM coroutines (`PROCESS`/`NEWPROCESS`/`TRANSFER`/`IOTRANSFER`);
 Unicode (`UCHAR` codepoints, `UString`, UTF-8 at the edges); ISO type
 conversions `T(x)` (incl. CONST folding); **separate compilation**
-(`lib.def lib.mod prog.mod` in one run → one image, `FROM`/`IMPORT`,
+(`M2 -c` per-unit objects, `M2make --separate`) and the one-shot
+multi-file mode (`lib.def lib.mod prog.mod` → one image, `FROM`/`IMPORT`,
 qualified `L.x`, opaque-type completion); **generics** — ISO 10514-2
 refinement form (`GENERIC … MODULE M(T:TYPE)` + `MODULE X = M(actuals)`)
 with type/value/procedure parameters, constraints

+ 45 - 0
compiler/run_tests.sh

@@ -676,6 +676,51 @@ twophase_fail t_fwdsel.mod "undeclared identifier"
 twophase_fail t_fwdprop.mod "undeclared identifier"
 twophase_fail t_bad_fwd.mod "undeclared identifier"
 
+# --- separate compilation: compile one unit against .def interfaces,
+#     link objects with cc; library objects must not define main. ---
+sep_compile() {
+  # $1 = module name, $2 = target source, rest = interface files
+  mod="$1"; target="$2"; shift 2
+  rm -f "gen_ssa/$mod.ssa" "gen_ssa/$mod.s" "gen_ssa/$mod.o"
+  if ! ./M2 -c "$@" "$target" 2>&1 | grep -q "Parsed correctly"; then
+    return 1
+  fi
+  [ -f "gen_ssa/$mod.ssa" ] || return 1
+  qbe -o "gen_ssa/$mod.s" "gen_ssa/$mod.ssa" 2> "gen_ssa/$mod.qbeerr" || return 1
+  cc -c "gen_ssa/$mod.s" -o "gen_ssa/$mod.o" 2> "gen_ssa/$mod.ccerr" || return 1
+  return 0
+}
+
+expect_separate() {
+  # $1 = program module, $2 = expected exit code, $3 = library module,
+  # $4 = library def, $5 = library mod, $6 = program source
+  prog="$1"; want="$2"; lib="$3"; ldef="$4"; lmod="$5"; psrc="$6"
+  if ! sep_compile "$lib" "tests/$lmod" "tests/$ldef"; then
+    fail=$((fail+1)); echo "FAIL(separate): $lib compile"; return
+  fi
+  if grep -q '\$main' "gen_ssa/$lib.ssa"; then
+    fail=$((fail+1)); echo "FAIL(separate): $lib defines main"; return
+  fi
+  if ! sep_compile "$prog" "tests/$psrc" "tests/$ldef"; then
+    fail=$((fail+1)); echo "FAIL(separate): $prog compile"; return
+  fi
+  if ! grep -q '\$main' "gen_ssa/$prog.ssa"; then
+    fail=$((fail+1)); echo "FAIL(separate): $prog has no main"; return
+  fi
+  if ! cc "gen_ssa/$prog.o" "gen_ssa/$lib.o" "$SHIM"        -o "gen_ssa/$prog" -lm 2> "gen_ssa/$prog.ccerr"; then
+    fail=$((fail+1)); echo "FAIL(separate): $prog link"; cat "gen_ssa/$prog.ccerr"; return
+  fi
+  timeout 10 "gen_ssa/$prog" >/dev/null 2>&1; got=$?
+  if [ "$got" = "$want" ]; then
+    pass=$((pass+1)); echo "PASS(separate): $prog -> $got"
+  else
+    fail=$((fail+1)); echo "FAIL(separate): $prog got $got want $want"
+  fi
+}
+
+expect_separate SepProg 42 SepLib sep_lib.def sep_lib.mod sep_prog.mod
+expect_separate SepEmptyProg 42 SepEmpty sep_empty.def sep_empty.mod sep_empty_prog.mod
+
 # --- M2 process exit codes: 0 = ok, 1 = compile errors, 2 = usage/IO ---
 check_exit() {
   # $1 = description, $2 = expected rc, rest = args

+ 6 - 0
compiler/src/Lower.def

@@ -16,6 +16,12 @@ IMPORT AST, SymTab, QbeGen;
 PROCEDURE BeginSession (progName: ARRAY OF CHAR);
 PROCEDURE SetLegacyOpen1D;
 (* Bootstrap compatibility: use legacy {count,data} 1-D open ABI. *)
+PROCEDURE SetSeparate (b: BOOLEAN);
+(* M2 -c: bodyless modules emit an empty <module>_init for cross-object
+   init calls. *)
+PROCEDURE SetDefUnit (b: BOOLEAN);
+(* Lowering the target's own .def just to emit exported data; do not
+   emit a second init. *)
 PROCEDURE BeginUnit;
 PROCEDURE LowerUnitNow (unit: AST.Node; isProg: BOOLEAN);
 PROCEDURE EndSession;

+ 21 - 1
compiler/src/Lower.mod

@@ -60,6 +60,8 @@ VAR
   nUserLab : CARDINAL;
   lastView : BOOLEAN;   (* last LowerDesign result is a multi-array view *)
   legacyOpen1D : BOOLEAN; (* bootstrap: keep the legacy 1-D open ABI *)
+  separateMode : BOOLEAN; (* M2 -c: per-object emission, empty inits *)
+  defUnitMode  : BOOLEAN; (* lowering the target interface for data *)
   userLabNm : ARRAY [0 .. 255 * 64 - 1] OF CHAR;
   userLabQ : ARRAY [0 .. 255] OF QbeGen.QVal;
   (* Scoping: each symbol records the procedure that owns it (-1 =
@@ -3089,6 +3091,13 @@ PROCEDURE LowerOneUnit (u: AST.Node; isProg: BOOLEAN);
       QbeGen.BeginInit(nm);
       EmitCountInits;
       QbeGen.EndInit
+    ELSIF separateMode AND NOT defUnitMode THEN
+      (* Every object exports <module>_init, even when the module has
+         no BEGIN body, so a program can call all imported inits.
+         A definition lowered only to materialise its globals must not
+         define a second init. *)
+      QbeGen.BeginInit(nm);
+      QbeGen.EndInit
     END
   END LowerOneUnit;
 
@@ -3097,6 +3106,16 @@ PROCEDURE SetLegacyOpen1D;
     legacyOpen1D := TRUE
   END SetLegacyOpen1D;
 
+PROCEDURE SetSeparate (b: BOOLEAN);
+  BEGIN
+    separateMode := b
+  END SetSeparate;
+
+PROCEDURE SetDefUnit (b: BOOLEAN);
+  BEGIN
+    defUnitMode := b
+  END SetDefUnit;
+
 PROCEDURE BeginSession (progName: ARRAY OF CHAR);
 (* Per-unit Lower emission.  The grammar still runs its inline emit while
    parsing, but under NoEmit it only mutates QbeGen's counters (nothing
@@ -3147,5 +3166,6 @@ PROCEDURE EndSession;
   END EndSession;
 
 BEGIN
-  scopeTop := 0; curScopeProc := -1; emitProc := -1
+  scopeTop := 0; curScopeProc := -1; emitProc := -1;
+  separateMode := FALSE; defUnitMode := FALSE
 END Lower.

+ 9 - 2
compiler/src/QbeGen.def

@@ -67,14 +67,21 @@ PROCEDURE SaveSession;
 PROCEDURE RestoreSession;
 (* Snapshot/restore the session counters (Lower per-unit emit). *)
 
+PROCEDURE SetProgramMode (isProgram: BOOLEAN);
+(* FALSE: emit a library object without main/ExitCode. *)
+PROCEDURE SetSeparateMode (b: BOOLEAN);
+(* M2 -c mode: per-unit object, bodyless modules still get an init. *)
+PROCEDURE NoteExternalInit (mod: ARRAY OF CHAR);
+(* Program object: call imported <mod>_init before the target init. *)
+
 PROCEDURE SetModule (name: ARRAY OF CHAR);
 (* Names the module being compiled; its globals emit as
    "<mod>_<name>" so separate modules never collide. *)
 
 
 PROCEDURE Mangled (pname: ARRAY OF CHAR; uid: CARDINAL; VAR q: QVal);
-(* q := "name_uid" (function symbols; same-named methods never
-   collide; deterministic). *)
+(* Stable function symbol: external link name, module-level
+   <module>__<name>, or a module-qualified uid for nested procedures. *)
 
 PROCEDURE BeginFunc (mangled: ARRAY OF CHAR);
 (* Opens function context (locals cleared, header buffering). *)

+ 180 - 68
compiler/src/QbeGen.mod

@@ -70,6 +70,10 @@ VAR
   outSel : CARDINAL;  (* 0 = file, else nestBufs[outSel-1] *)
   nInit : CARDINAL;
   initNames : ARRAY [0 .. 31] OF QVal;
+  nExtInit : CARDINAL;
+  extInitNames : ARRAY [0 .. 31] OF QVal;
+  programMode : BOOLEAN;  (* separate objects: FALSE emits no main *)
+  separateMode : BOOLEAN; (* per-object mode (no def code, empty inits) *)
   nFini : CARDINAL;
   finiNames : ARRAY [0 .. 31] OF QVal;
   sessBuf : ARRAY [0 .. 8388607] OF CHAR;  (* whole image, buffered (8 MiB:
@@ -316,6 +320,32 @@ PROCEDURE SetModule (name: ARRAY OF CHAR);
     Cpy(curModName, name)
   END SetModule;
 
+PROCEDURE SetProgramMode (isProgram: BOOLEAN);
+(* Separate compilation: FALSE makes EndModule emit a library object
+   (no main, no ExitCode return); TRUE keeps the program image. *)
+  BEGIN
+    programMode := isProgram
+  END SetProgramMode;
+
+PROCEDURE SetSeparateMode (b: BOOLEAN);
+(* TRUE for M2 -c: Lower emits an empty init for bodyless modules and
+   the driver skips lowering definition units. *)
+  BEGIN
+    separateMode := b
+  END SetSeparateMode;
+
+PROCEDURE NoteExternalInit (mod: ARRAY OF CHAR);
+(* Program object: call an imported module's <mod>_init before the
+   target module's init.  The driver adds one per imported DEFINITION. *)
+  VAR sym : QVal;
+  BEGIN
+    IF nExtInit > HIGH(extInitNames) THEN RETURN END;
+    Cpy(sym, mod);
+    App(sym, "_init");
+    Cpy(extInitNames[nExtInit], sym);
+    INC(nExtInit)
+  END NoteExternalInit;
+
 PROCEDURE SymRef (name: ARRAY OF CHAR; VAR ref: QVal);
 (* "$<mod>_<sym>" for module-level entries (avoids cross-module
    collisions); "$<name>" fallback otherwise. *)
@@ -596,7 +626,8 @@ PROCEDURE OpenModule (name: ARRAY OF CHAR);
     nLoc := 0; nPar := 0; nArg := 0; nn := 0; callDepth := 0;
     recvArmed := FALSE;
     funcDepth := 0; scopeTop := 0; scopeBase[0] := 0;
-    nInit := 0; nFini := 0;
+    nInit := 0; nFini := 0; nExtInit := 0;
+    programMode := TRUE;
     outSel := 0;
     sessUsed := 0; sessBuf[0] := CHR(0);
     delayTop := 0;
@@ -613,7 +644,7 @@ PROCEDURE DataLine (name: ARRAY OF CHAR; isReal: BOOLEAN;
                    init: ARRAY OF CHAR);
   BEGIN
     IF NOT opened THEN RETURN END;
-    W("data $"); W(name);
+    W("export data $"); W(name);
     IF isReal THEN W(" = { d ") ELSE W(" = { w ") END;
     W(init);
     WL(" }")
@@ -623,7 +654,7 @@ PROCEDURE DataLineB (name: ARRAY OF CHAR; init: ARRAY OF CHAR);
 (* A one-byte global (BYTE/SBYTE). *)
   BEGIN
     IF NOT opened THEN RETURN END;
-    W("data $"); W(name);
+    W("export data $"); W(name);
     W(" = { b ");
     W(init);
     WL(" }")
@@ -633,7 +664,7 @@ PROCEDURE DataLineH (name: ARRAY OF CHAR; init: ARRAY OF CHAR);
 (* A 16-bit global (WORD16/INTEGER16). *)
   BEGIN
     IF NOT opened THEN RETURN END;
-    W("data $"); W(name);
+    W("export data $"); W(name);
     W(" = { h ");
     W(init);
     WL(" }")
@@ -645,7 +676,7 @@ PROCEDURE DataLinePacked (name: ARRAY OF CHAR; t: INTEGER);
   BEGIN
     IF NOT opened THEN RETURN END;
     nb := SymTab.SetBytes(t);
-    W("data $"); W(name);
+    W("export data $"); W(name);
     W(" = { ");
     IF nb = 1 THEN W("b 0")
     ELSIF nb = 2 THEN W("h 0")
@@ -657,7 +688,7 @@ PROCEDURE DataLinePacked (name: ARRAY OF CHAR; t: INTEGER);
 PROCEDURE DataLineL (name: ARRAY OF CHAR; init: ARRAY OF CHAR);
   BEGIN
     IF NOT opened THEN RETURN END;
-    W("data $"); W(name);
+    W("export data $"); W(name);
     W(" = { l ");
     W(init);
     WL(" }")
@@ -811,6 +842,11 @@ PROCEDURE BeginBody;
       INC(i)
     END;
     i := 0;
+    WHILE i < nExtInit DO
+      W("  call $"); W(extInitNames[i]); WL("()");
+      INC(i)
+    END;
+    i := 0;
     WHILE i < nInit DO
       W("  call $"); W(initNames[i]); WL("()");
       INC(i)
@@ -826,7 +862,7 @@ PROCEDURE FlushSets;
     IF NOT opened THEN RETURN END;
     k := 0;
     WHILE k < setN DO
-      W("data $"); W(setNam[k]); W(" = { ");
+      W("export data $"); W(setNam[k]); W(" = { ");
       w := setW[k];
       IF w = 0 THEN w := 1 END;
       i := 0;
@@ -880,18 +916,20 @@ PROCEDURE EndModule (name: ARRAY OF CHAR);
        not flush/close/write while the legacy emit is suppressed.  The
        real session image is written by Lower.EndSession. *)
     IF noEmit THEN RETURN END;
-    IF NOT inBody THEN BeginBody END;
-    EmitFiniCalls(name);
-    IF SymTab.Lookup("ExitCode")
+    IF programMode THEN
+      IF NOT inBody THEN BeginBody END;
+      EmitFiniCalls(name);
+      IF SymTab.Lookup("ExitCode")
  AND (SymTab.SymKind("ExitCode") = SymTab.KindVar)
  AND SymTab.IsIntFamily(SymTab.SymType("ExitCode")) THEN
-      Cpy(ref, "$"); App(ref, name); App(ref, "_ExitCode");
-      W("  %ec =w loadw "); WL(ref);
-      WL("  ret %ec")
-    ELSE
-      WL("  ret 0")
+        Cpy(ref, "$"); App(ref, name); App(ref, "_ExitCode");
+        W("  %ec =w loadw "); WL(ref);
+        WL("  ret %ec")
+      ELSE
+        WL("  ret 0")
+      END;
+      WL("}")
     END;
-    WL("}");
     (* hoisted nested functions, then deferred data *)
     i := 0;
     WHILE i <= HIGH(nestBufs) DO
@@ -1054,9 +1092,13 @@ PROCEDURE ResClass (t: INTEGER): CHAR;
   END ResClass;
 
 PROCEDURE Mangled (pname: ARRAY OF CHAR; uid: CARDINAL; VAR q: QVal);
-(* External procedures bind to their C link name; everything else
-   mangles to <name>_<uid> (deterministic, collision-free). *)
-  VAR link, base : SymTab.Name;
+(* External procedures bind to their C link name.  Module-level
+   procedures get a stable, module-qualified name (<module>__<name>)
+   so objects compiled in separate sessions agree.  Nested procedures
+   are local to their module object; they keep a deterministic uid
+   suffix, prefixed by the module to avoid cross-object collisions. *)
+  VAR link, base, mod : SymTab.Name;
+      depth : CARDINAL; classIdx : INTEGER; num : QVal;
   BEGIN
     IF SymTab.ProcLink(pname, link) THEN
       Cpy(q, link);
@@ -1065,9 +1107,36 @@ PROCEDURE Mangled (pname: ARRAY OF CHAR; uid: CARDINAL; VAR q: QVal);
     IF NOT SymTab.SymBase(pname, base) THEN
       IF NOT SymTab.ProcBaseByUid(uid, base) THEN Cpy(base, pname) END
     END;
-    Cpy(q, base);
-    App(q, "_");
-    AppNum(q, uid)
+    IF SymTab.SymBase(pname, base) THEN
+      IF SymTab.ProcLink(base, link) THEN
+        Cpy(q, link);
+        RETURN
+      END
+    END;
+    IF SymTab.ProcModuleByUid(uid, mod, depth)
+       AND (mod[0] # CHR(0)) THEN
+      Cpy(q, mod);
+      App(q, "__");
+      App(q, base);
+      classIdx := SymTab.ProcClassByUid(uid);
+      IF classIdx # SymTab.InvalidType THEN
+        IF SymTab.TypeStableName(classIdx, link, base) THEN
+          App(q, "_"); App(q, link); App(q, "__"); App(q, base)
+        ELSE
+          App(q, "_c");
+          IntStr(classIdx, num);
+          App(q, num)
+        END
+      END;
+      IF depth > 0 THEN
+        App(q, "_");
+        AppNum(q, uid)
+      END
+    ELSE
+      Cpy(q, base);
+      App(q, "_");
+      AppNum(q, uid)
+    END
   END Mangled;
 
 PROCEDURE AllocLocal (t: INTEGER; VAR slot: QVal);
@@ -2425,7 +2494,7 @@ PROCEDURE ArrData (name: ARRAY OF CHAR; t: INTEGER);
 
    BEGIN
     IF NOT opened THEN RETURN END;
-    W("data $"); W(name);
+    W("export data $"); W(name);
     W(" = { ");
     ArrBodyItems(name, t);
     WL(" }");
@@ -2553,12 +2622,19 @@ PROCEDURE RecItems (t: SymTab.TypeIndex; prefix: ARRAY OF CHAR;
   END RecItems;
 
 PROCEDURE VtRef (t: INTEGER; VAR q: QVal);
-(* The class's vtable symbol ($vt_<typeindex>). *)
-  VAR bv: QVal;
+(* Stable vtable symbol: $<module>__<class>__vt.  Fall back to a
+   module-qualified type index for anonymous types. *)
+  VAR bv: QVal; mod, nm: SymTab.Name;
   BEGIN
-    Cpy(q, "$vt_");
-    IntStr(t, bv);
-    App(q, bv)
+    IF SymTab.TypeStableName(t, mod, nm) THEN
+      Cpy(q, "$"); App(q, mod); App(q, "__"); App(q, nm); App(q, "__vt")
+    ELSE
+      Cpy(q, "$");
+      App(q, curModName);
+      App(q, "_vt_");
+      IntStr(t, bv);
+      App(q, bv)
+    END
   END VtRef;
 
 PROCEDURE EmitVTables;
@@ -2566,31 +2642,38 @@ PROCEDURE EmitVTables;
    method code addresses. *)
   VAR k, j, n: CARDINAL;
     t: INTEGER;
-    nm: SymTab.Name;
+    nm, cname, cmod: SymTab.Name;
     mg, vt: QVal;
+    own: BOOLEAN;
   BEGIN
     IF NOT opened THEN RETURN END;
     k := 0;
     WHILE k < SymTab.VtClassCount() DO
       t := SymTab.VtClassAt(k);
-      n := SymTab.VtCount(t);
-      VtRef(t, vt);
-      W("data "); W(vt); W(" = { ");
-      j := 0;
-      WHILE j < n DO
-        IF SymTab.VtName(t, j, nm) THEN
-          IF j > 0 THEN W(", ") END;
-          IF SymTab.VtHasBody(t, j) THEN
-            Mangled(nm, SymTab.VtUid(t, j), mg);
-            W("l $"); W(mg)
-          ELSE
-            W("l 0")   (* declared but never implemented *)
-          END
+      own := TRUE;
+      IF SymTab.TypeStableName(t, cmod, cname) THEN
+        own := SymTab.Equal(cmod, curModName) OR NOT separateMode
+      END;
+      IF own THEN
+        n := SymTab.VtCount(t);
+        VtRef(t, vt);
+        W("export data "); W(vt); W(" = { ");
+        j := 0;
+        WHILE j < n DO
+          IF SymTab.VtName(t, j, nm) THEN
+            IF j > 0 THEN W(", ") END;
+            IF SymTab.VtHasBody(t, j) THEN
+              Mangled(nm, SymTab.VtUid(t, j), mg);
+              W("l $"); W(mg)
+            ELSE
+              W("l 0")   (* declared but never implemented *)
+            END
+          END;
+          INC(j)
         END;
-        INC(j)
+        IF n = 0 THEN W("l 0") END;
+        WL(" }")
       END;
-      IF n = 0 THEN W("l 0") END;
-      WL(" }");
       INC(k)
     END
   END EmitVTables;
@@ -2604,11 +2687,11 @@ PROCEDURE DeclRec (name: ARRAY OF CHAR; t: INTEGER);
       (* variant record: fields overlay, so emit one zero-filled blob
          of the record's (max) size *)
       IntStr(VAL(INTEGER, SymTab.TypeSize(t)), sz);
-      W("data $"); W(name); W(" = { z "); W(sz); WL(" }");
+      W("export data $"); W(name); W(" = { z "); W(sz); WL(" }");
       RETURN
     END;
     RecStatics(name, t);
-    W("data $"); W(name);
+    W("export data $"); W(name);
     W(" = { ");
     first := TRUE;
     (* a class with a vtable stores its vtable pointer at offset 0 *)
@@ -2681,7 +2764,7 @@ PROCEDURE DeclSet (name: ARRAY OF CHAR; t: INTEGER);
     IF NOT opened THEN RETURN END;
     w := SymTab.SetWords(t);
     IF w = 0 THEN w := 1 END;
-    W("data $"); W(name);
+    W("export data $"); W(name);
     W(" = { w 0");
     i := 1;
     WHILE i < w DO
@@ -2702,7 +2785,8 @@ PROCEDURE PoolSet (mask: ARRAY OF CARDINAL; words: CARDINAL; VAR q: QVal);
     END;
     WHILE k <= 15 DO setMask[setN][k] := 0; INC(k) END;
     setW[setN] := words;
-    Cpy(setNam[setN], "set"); AppNum(setNam[setN], setN);
+    Cpy(setNam[setN], curModName); App(setNam[setN], "_set");
+    AppNum(setNam[setN], setN);
     Cpy(q, "$"); App(q, setNam[setN]);
     INC(setN)
   END PoolSet;
@@ -3681,6 +3765,32 @@ PROCEDURE CopyOldNestedToView (dst, src: ARRAY OF CHAR;
     END
   END CopyOldNestedToView;
 
+PROCEDURE IsCtorRef (v: ARRAY OF CHAR): BOOLEAN;
+(* TRUE for $ctoN and $<module>_ctoN. *)
+  VAR i: CARDINAL;
+  BEGIN
+    IF (v[0] # "$") OR (v[1] = CHR(0)) THEN RETURN FALSE END;
+    IF (v[1] = "c") AND (v[2] = "t") AND (v[3] = "o") THEN RETURN TRUE END;
+    i := 1;
+    WHILE (i <= HIGH(v)) AND (v[i] # CHR(0)) AND (v[i] # "_") DO INC(i) END;
+    RETURN (i + 3 <= HIGH(v)) AND (v[i] = "_")
+       AND (v[i + 1] = "c") AND (v[i + 2] = "t") AND (v[i + 3] = "o")
+  END IsCtorRef;
+
+PROCEDURE IsStrRef (v: ARRAY OF CHAR): BOOLEAN;
+(* TRUE when v names a recorded string literal ($strN or $<mod>_strN). *)
+  VAR j: CARDINAL;
+      sv: QVal;
+  BEGIN
+    j := 0;
+    WHILE j < nStr DO
+      Cpy(sv, "$"); App(sv, strNams[j]);
+      IF SymTab.Equal(sv, v) THEN RETURN TRUE END;
+      INC(j)
+    END;
+    RETURN FALSE
+  END IsStrRef;
+
 PROCEDURE CopyArray (dst: ARRAY OF CHAR; src: ARRAY OF CHAR;
                       t: INTEGER);
 (* Whole-array copy with runtime count check + blit. Nested levels
@@ -3696,8 +3806,7 @@ PROCEDURE CopyArray (dst: ARRAY OF CHAR; src: ARRAY OF CHAR;
     ltop, lbody, lend: QVal;
   BEGIN
     IF SymTab.IsMultiArray(t) THEN
-      IF (src[0] = "$") AND (src[1] = "c") AND (src[2] = "t")
-         AND (src[3] = "o") THEN
+      IF IsCtorRef(src) THEN
         CopyOldNestedToView(dst, src, t, SymTab.ArrayDepth(t))
       ELSE
         (* src/dst are ArrayView pointers; copy the flat payload only. *)
@@ -3803,7 +3912,8 @@ PROCEDURE DeclStr (text: ARRAY OF CHAR; VAR q: QVal);
    appear outside functions, but literals occur mid-body. *)
   VAR nm: QVal;
   BEGIN
-    Cpy(nm, "str");
+    Cpy(nm, curModName);
+    App(nm, "_str");
     AppNum(nm, nStr);
     IF nStr <= HIGH(strNams) THEN
       Cpy(strNams[nStr], nm);
@@ -3872,7 +3982,7 @@ PROCEDURE DeclUStr (text: ARRAY OF CHAR; VAR q: QVal;
     IF cnt = 1 THEN
       single := TRUE; cp := ustrPool[base]
     ELSE
-      Cpy(nm, "ustr"); AppNum(nm, ustrN);
+      Cpy(nm, curModName); App(nm, "_ustr"); AppNum(nm, ustrN);
       IF ustrN <= HIGH(ustrNams) THEN
         Cpy(ustrNams[ustrN], nm);
         ustrStart[ustrN] := base; ustrCount[ustrN] := cnt; INC(ustrN)
@@ -3888,7 +3998,7 @@ PROCEDURE FlushUStrings;
     IF NOT opened THEN RETURN END;
     k := 0;
     WHILE k < ustrN DO
-      W("data $"); W(ustrNams[k]); W(" = { l ");
+      W("export data $"); W(ustrNams[k]); W(" = { l ");
       IntStr(VAL(INTEGER, ustrCount[k]), bv); W(bv);
       i := 0;
       WHILE i < ustrCount[k] DO
@@ -3998,7 +4108,7 @@ PROCEDURE FlushStrings;
     WHILE k < nStr DO
       L := Len(strTexts[k]);
       IF L >= 2 THEN
-        W("data $"); W(strNams[k]);
+        W("export data $"); W(strNams[k]);
         W(" = { l ");
         IntStr(VAL(INTEGER, L - 2), bv);
         W(bv);
@@ -4048,7 +4158,7 @@ PROCEDURE DeclMultiArr (name: ARRAY OF CHAR; t: INTEGER);
       ELSE
         Cpy(nxt, "0")
       END;
-      W("data $"); W(lab); W(" = { l ");
+      W("export data $"); W(lab); W(" = { l ");
       IntStr(VAL(INTEGER, count), bv); W(bv);
       W(", l "); IntStr(VAL(INTEGER, stride), sv); W(sv);
       W(", l "); W(sv);
@@ -4057,7 +4167,7 @@ PROCEDURE DeclMultiArr (name: ARRAY OF CHAR; t: INTEGER);
       cur := SymTab.ArrayElem(cur);
       INC(k)
     END;
-    W("data $"); W(name); W("_data = { z ");
+    W("export data $"); W(name); W("_data = { z ");
     IntStr(VAL(INTEGER, payload), pv); W(pv); WL(" }")
   END DeclMultiArr;
 
@@ -4080,13 +4190,14 @@ PROCEDURE DeclArr (name: ARRAY OF CHAR; t: INTEGER);
 
 PROCEDURE IsBakeOp (v: ARRAY OF CHAR): BOOLEAN;
 (* TRUE when v may be embedded in a static descriptor: an immediate,
-   or a pooled constructor/label reference ($ctoN).  A plain global
-   address ($mod_var) is NOT bakeable into an inline field. *)
+   or a pooled constructor/label reference ($ctoN / $<mod>_ctoN).
+   A plain global address ($mod_var) is NOT bakeable into an inline
+   field. *)
+  VAR i: CARDINAL;
   BEGIN
     IF (v[0] = CHR(0)) OR (v[0] = "%") THEN RETURN FALSE END;
     IF v[0] # "$" THEN RETURN TRUE END;
-    (* accept $ctoN only *)
-    RETURN (v[1] = "c") AND (v[2] = "t") AND (v[3] = "o")
+    RETURN IsCtorRef(v)
   END IsBakeOp;
 
 PROCEDURE CtorItem (VAR buf: ARRAY OF CHAR; v: ARRAY OF CHAR;
@@ -4259,7 +4370,8 @@ PROCEDURE StrDesc (v: ARRAY OF CHAR; elemT: INTEGER; VAR out: QVal);
       INC(i)
     END;
     App(buf, ", b 0");
-    Cpy(ctorNam[ctorN], "cto"); AppNum(ctorNam[ctorN], ctorN);
+    Cpy(ctorNam[ctorN], curModName); App(ctorNam[ctorN], "_cto");
+    AppNum(ctorNam[ctorN], ctorN);
     Cpy(ctorTxt[ctorN], buf);
     ctorUse[ctorN] := TRUE;
     INC(ctorN);
@@ -4290,8 +4402,7 @@ PROCEDURE CtorElem (v: ARRAY OF CHAR);
     t := ctorTyp[ctorTop - 1];
     elemT := CtorElemType(t, ctorCnt[ctorTop - 1]);
     cls := SymTab.ClassOf(elemT);
-    isStr := (Len(v) > 3) AND (v[0] = "$")
-             AND (v[1] = "s") AND (v[2] = "t");
+    isStr := IsStrRef(v);
     IF cls = SymTab.ClChar THEN
       IF isStr AND FindStrPos(v, cnt) THEN
         k := 1;
@@ -4500,7 +4611,7 @@ PROCEDURE CtorRecStatic (VAR s: ARRAY OF CHAR; lvl: CARDINAL;
       IF (v[0] # CHR(0)) AND (cls = SymTab.ClArray)
          AND SymTab.IsMultiArray(ft) THEN
         flat[0] := CHR(0);
-        IF (v[0] = "$") AND (v[1] = "c") THEN
+        IF IsCtorRef(v) THEN
           FlattenCtorText(v, ft, flat)
         END;
         IF first THEN first := FALSE ELSE App(s, ", ") END;
@@ -4600,7 +4711,8 @@ PROCEDURE CtorEnd (VAR q: QVal);
       ELSE
         CtorRecStatic(s, ctorTop, t, ctorCnt[ctorTop])
       END;
-      Cpy(ctorNam[ctorN], "cto"); AppNum(ctorNam[ctorN], ctorN);
+      Cpy(ctorNam[ctorN], curModName); App(ctorNam[ctorN], "_cto");
+      AppNum(ctorNam[ctorN], ctorN);
       Cpy(ctorTxt[ctorN], s);
       ctorUse[ctorN] := TRUE;
       INC(ctorN);
@@ -4700,7 +4812,7 @@ PROCEDURE FlushCtors;
     k := 0;
     WHILE k < ctorN DO
       IF ctorUse[k] THEN
-        W("data $"); W(ctorNam[k]); W(" = { "); W(ctorTxt[k]); WL(" }")
+        W("export data $"); W(ctorNam[k]); W(" = { "); W(ctorTxt[k]); WL(" }")
       END;
       INC(k)
     END
@@ -4940,7 +5052,7 @@ PROCEDURE DeclRecTemplate (varName: ARRAY OF CHAR; rec: INTEGER);
     IF NOT opened THEN RETURN END;
     Cpy(g, curModName); App(g, "_"); App(g, varName); App(g, "_tmpl");
     RecStatics(g, rec);
-    W("data $"); W(g); W(" = { ");
+    W("export data $"); W(g); W(" = { ");
     first := TRUE;
     RecItems(rec, g, first);
     WL(" }")

+ 7 - 0
compiler/src/SymTab.def

@@ -297,6 +297,13 @@ PROCEDURE MaterializeAlias (mod, name: ARRAY OF CHAR;
 
 PROCEDURE ProcBaseByUid (uid: CARDINAL; VAR base: Name): BOOLEAN;
 (* Base (mangling) name of a procedure by uid. *)
+PROCEDURE ProcModuleByUid (uid: CARDINAL; VAR mod: Name;
+                           VAR depth: CARDINAL): BOOLEAN;
+(* Defining module and lexical depth of a procedure by uid. *)
+PROCEDURE TypeStableName (t: TypeIndex; VAR mod, name: Name): BOOLEAN;
+(* Stable (module, type-name) for a named type/class. *)
+PROCEDURE ProcClassByUid (uid: CARDINAL): TypeIndex;
+(* Class owning a method declared with this uid, or InvalidType. *)
 PROCEDURE SymBase (name: ARRAY OF CHAR; VAR base: Name): BOOLEAN;
 
 PROCEDURE QualProcInfo (mod, name: ARRAY OF CHAR; VAR uid, depth: CARDINAL;

+ 67 - 3
compiler/src/SymTab.mod

@@ -105,6 +105,9 @@ VAR
   (* deferred procedure bodies (two-phase parse): a skipped body's own
      scope, saved while still current, restored for the re-parse pass *)
   uidBase : ARRAY [0 .. 1023] OF Name;  (* uid -> base name (mangling) *)
+  uidMod  : ARRAY [0 .. 1023] OF Name;  (* uid -> defining module *)
+  uidDepth : ARRAY [0 .. 1023] OF CARDINAL; (* uid -> lexical depth *)
+  uidClass : ARRAY [0 .. 1023] OF TypeIndex; (* uid -> class method owner *)
   nDefer : CARDINAL;
   deferProc : ARRAY [0 .. MaxDefer - 1] OF SymPtr;
   deferScope : ARRAY [0 .. MaxDefer - 1] OF ScopePtr;
@@ -113,6 +116,8 @@ VAR
   orderCheck : BOOLEAN;   (* enforce use-before-decl in a deferred body *)
   orderSeq : CARDINAL;    (* the enclosing procedure's declaration seq *)
   tparent : ARRAY [0 .. MaxTypes - 1] OF TypeIndex;
+  tname : ARRAY [0 .. MaxTypes - 1] OF Name;  (* stable type/class name *)
+  tmod  : ARRAY [0 .. MaxTypes - 1] OF Name;  (* defining module *)
   tscope : ARRAY [0 .. MaxTypes - 1] OF ScopePtr;
   tdone : ARRAY [0 .. MaxTypes - 1] OF BOOLEAN;
   (* per-class vtables: each class owns a slice of the shared pool *)
@@ -686,7 +691,15 @@ PROCEDURE SetSymType (name: ARRAY OF CHAR; t: TypeIndex);
   VAR node: SymPtr;
   BEGIN
     node := Find(name);
-    IF node # NIL THEN node^.typ := t END
+    IF node # NIL THEN
+      node^.typ := t;
+      IF (node^.kind = KindType) AND (t >= 0) AND (t < VAL(INTEGER, nTypes)) THEN
+        Assign(tname[t], name);
+        IF curMod >= 0 THEN Assign(tmod[t], modNames[curMod])
+        ELSE tmod[t][0] := CHR(0)
+        END
+      END
+    END
   END SetSymType;
 
 PROCEDURE SymKind (name: ARRAY OF CHAR): INTEGER;
@@ -1093,6 +1106,7 @@ PROCEDURE PushClassScope (t: TypeIndex);
     r := Resolve(t);
     IF (r = InvalidType) OR (tform[r] # FClass) THEN RETURN END;
     PushScope;
+    curScope^.ofRec := r;
     tscope[r] := curScope
   END PushClassScope;
 
@@ -2389,7 +2403,16 @@ PROCEDURE EnterProc (name: ARRAY OF CHAR): BOOLEAN;
     node^.fsnap := FALSE;
     node^.link[0] := CHR(0);
     node^.uid := nextUid; INC(nextUid);
-    IF node^.uid <= HIGH(uidBase) THEN Assign(uidBase[node^.uid], name) END;
+    IF node^.uid <= HIGH(uidBase) THEN
+      Assign(uidBase[node^.uid], name);
+      IF curMod >= 0 THEN Assign(uidMod[node^.uid], modNames[curMod])
+      ELSE uidMod[node^.uid][0] := CHR(0)
+      END;
+      uidDepth[node^.uid] := nProc;
+      IF curScope # NIL THEN uidClass[node^.uid] := curScope^.ofRec
+      ELSE uidClass[node^.uid] := InvalidType
+      END
+    END;
     node^.fdep := nProc;
     node^.vslot := -1;
     node^.hasBody := FALSE;
@@ -2538,7 +2561,14 @@ PROCEDURE DeclareCProc (name: ARRAY OF CHAR): BOOLEAN;
     node^.fsnap := FALSE;
     Assign(node^.link, name);
     node^.uid := nextUid; INC(nextUid);
-    IF node^.uid <= HIGH(uidBase) THEN Assign(uidBase[node^.uid], name) END;
+    IF node^.uid <= HIGH(uidBase) THEN
+      Assign(uidBase[node^.uid], name);
+      IF curMod >= 0 THEN Assign(uidMod[node^.uid], modNames[curMod])
+      ELSE uidMod[node^.uid][0] := CHR(0)
+      END;
+      uidDepth[node^.uid] := nProc;
+      uidClass[node^.uid] := InvalidType
+    END;
     node^.fdep := nProc;
     node^.vslot := -1;
     node^.hasBody := TRUE;
@@ -3188,6 +3218,40 @@ PROCEDURE ProcBaseByUid (uid: CARDINAL; VAR base: Name): BOOLEAN;
     RETURN TRUE
   END ProcBaseByUid;
 
+PROCEDURE TypeStableName (t: TypeIndex; VAR mod, name: Name): BOOLEAN;
+(* Stable (module, type-name) for a named type/class.  Vtables and class
+   methods use this instead of session-local type indices. *)
+  VAR r: TypeIndex;
+  BEGIN
+    mod[0] := CHR(0); name[0] := CHR(0);
+    r := Resolve(t);
+    IF (r < 0) OR (r >= VAL(INTEGER, nTypes)) OR (tname[r][0] = CHR(0)) THEN
+      RETURN FALSE
+    END;
+    Assign(mod, tmod[r]); Assign(name, tname[r]);
+    RETURN TRUE
+  END TypeStableName;
+
+PROCEDURE ProcClassByUid (uid: CARDINAL): TypeIndex;
+(* Class owning a method declared with this uid, or InvalidType. *)
+  BEGIN
+    IF (uid > HIGH(uidClass)) THEN RETURN InvalidType END;
+    RETURN uidClass[uid]
+  END ProcClassByUid;
+
+PROCEDURE ProcModuleByUid (uid: CARDINAL; VAR mod: Name;
+                           VAR depth: CARDINAL): BOOLEAN;
+(* Defining module and lexical depth by uid.  Separate compilation
+   needs this even when only a base name (not the module alias) reaches
+   the backend. *)
+  BEGIN
+    mod[0] := CHR(0); depth := 0;
+    IF (uid > HIGH(uidMod)) OR (uidMod[uid][0] = CHR(0)) THEN RETURN FALSE END;
+    Assign(mod, uidMod[uid]);
+    depth := uidDepth[uid];
+    RETURN TRUE
+  END ProcModuleByUid;
+
 PROCEDURE SymBase (name: ARRAY OF CHAR; VAR base: Name): BOOLEAN;
 (* Base symbol name of the visible entry (differs from `name` for
    qualified aliases). *)

+ 76 - 2
compiler/src/compiler.frm

@@ -285,6 +285,17 @@ MODULE -->Grammar;
       name[i] := CHR(0)
     END BaseName;
 
+  PROCEDURE FileExists (path: ARRAY OF CHAR): BOOLEAN;
+  (* The self-hosted FileIO has no Exists query; Open in input mode and
+     close immediately. *)
+    VAR f: FileIO.File; ok: BOOLEAN;
+  BEGIN
+    FileIO.Open(f, path, FALSE);
+    ok := FileIO.Okay;
+    IF ok THEN FileIO.Close(f) END;
+    RETURN ok
+  END FileExists;
+
   VAR
     sourceName, listName, progName: ARRAY [0 .. 255] OF CHAR;
     files: ARRAY [0 .. 127] OF ARRAY [0 .. 255] OF CHAR;
@@ -293,6 +304,11 @@ MODULE -->Grammar;
     f: CARDINAL;
     bad: BOOLEAN;
     doTwoPhase: BOOLEAN; (* two-phase parse: defer procedure bodies *)
+    separate: BOOLEAN;   (* M2 -c: one object, defs are interfaces *)
+    progIsProgram: BOOLEAN;
+    tmpCmd: ARRAY [0 .. 255] OF CHAR;
+    defNames: ARRAY [0 .. 127] OF ARRAY [0 .. 63] OF CHAR;
+    nDefNames: CARDINAL;
 
     (* generics (Increment 2): module name -> source path, def and impl *)
     genDefNm:  ARRAY [0 .. 31] OF ARRAY [0 .. 63] OF CHAR;
@@ -317,6 +333,23 @@ MODULE -->Grammar;
     RETURN FALSE
   END EqNm;
 
+  PROCEDURE EqNmCI (a, b: ARRAY OF CHAR): BOOLEAN;
+  (* Case-insensitive module/file-name comparison.  Source file names
+     are lower-case in many corpora while module identifiers are not. *)
+    VAR i: CARDINAL; ca, cb: CHAR;
+  BEGIN
+    i := 0;
+    WHILE (i <= HIGH(a)) AND (i <= HIGH(b)) DO
+      ca := a[i]; cb := b[i];
+      IF (ca >= "A") AND (ca <= "Z") THEN ca := CHR(ORD(ca) + 32) END;
+      IF (cb >= "A") AND (cb <= "Z") THEN cb := CHR(ORD(cb) + 32) END;
+      IF ca # cb THEN RETURN FALSE END;
+      IF ca = CHR(0) THEN RETURN TRUE END;
+      INC(i)
+    END;
+    RETURN FALSE
+  END EqNmCI;
+
   PROCEDURE NoteGen (name: ARRAY OF CHAR; isD: BOOLEAN;
                      path: ARRAY OF CHAR);
     VAR i: CARDINAL;
@@ -430,6 +463,8 @@ MODULE -->Grammar;
     IF bootstrapEnv[0] = "1" THEN Lower.SetLegacyOpen1D END;
     (* collect the session's source files (program module last) *)
     nFiles := 0;
+    separate := FALSE;
+    nDefNames := 0;
     doTwoPhase := TRUE;   (* two-phase parse on by default (-notwophase off) *)
     LOOP
       FileIO.NextParameter(sourceName);
@@ -438,6 +473,8 @@ MODULE -->Grammar;
         doTwoPhase := FALSE
       ELSIF EqNm(sourceName, "-legacyopen1d") THEN
         Lower.SetLegacyOpen1D
+      ELSIF EqNm(sourceName, "-c") THEN
+        separate := TRUE
       ELSE
         Strings.Assign(sourceName, files[nFiles]);
         INC(nFiles)
@@ -449,9 +486,23 @@ MODULE -->Grammar;
       exit(2)
     END;
 
+    (* M2 -c Foo.mod: pick up Foo.def automatically when it exists;
+       imported interfaces may still be supplied explicitly. *)
+    IF separate AND (nFiles = 1) THEN
+      ChangeExtension(files[0], ".def", tmpCmd);
+      IF (NOT EqNm(files[0], tmpCmd)) AND FileExists(tmpCmd) THEN
+        Strings.Assign(files[0], files[1]);
+        Strings.Assign(tmpCmd, files[0]);
+        nFiles := 2
+      END
+    END;
     BaseName(files[nFiles - 1], progName);
     SymTab.Init;
     QbeGen.OpenModule(progName);
+    IF separate THEN
+      Lower.SetSeparate(TRUE);
+      QbeGen.SetSeparateMode(TRUE)
+    END;
     Lower.BeginSession(progName);
     Error := StoreError;
 
@@ -485,8 +536,28 @@ MODULE -->Grammar;
         (* record the generic's source for later instantiation; do not
            emit the abstract template *)
         NoteGen(uname, UnitIsDefModule(), sourceName)
+      ELSIF separate AND UnitIsDefModule() AND (f < nFiles - 1) THEN
+        (* Interfaces are type-checking only, except the target's own
+           .def: lower that one to emit its exported globals/consts. *)
+        IF nDefNames <= HIGH(defNames) THEN
+          Strings.Assign(uname, defNames[nDefNames]);
+          INC(nDefNames)
+        END;
+        ChangeExtension(files[nFiles - 1], ".def", tmpCmd);
+        IF EqNm(sourceName, tmpCmd) THEN
+          Lower.SetDefUnit(TRUE);
+          Lower.LowerUnitNow(GetUnit(), FALSE);
+          Lower.SetDefUnit(FALSE)
+        ELSE
+          QbeGen.NoteExternalInit(uname)
+        END
       ELSE
-        Lower.LowerUnitNow(GetUnit(), f = nFiles - 1)
+        IF separate THEN
+          progIsProgram := SymTab.HaveProgram();
+          Lower.LowerUnitNow(GetUnit(), progIsProgram)
+        ELSE
+          Lower.LowerUnitNow(GetUnit(), f = nFiles - 1)
+        END
       END;
       PrintListing;
       IF lst # FileIO.StdOut THEN FileIO.Close(lst) END;
@@ -503,9 +574,12 @@ MODULE -->Grammar;
       INC(f)
     END;
 
+    IF separate THEN
+      QbeGen.SetProgramMode(SymTab.HaveProgram())
+    END;
     Lower.EndSession;
 
-    IF bad OR NOT SymTab.HaveProgram() THEN
+    IF bad OR (NOT separate AND NOT SymTab.HaveProgram()) THEN
       FileIO.WriteString(FileIO.StdOut, "Incorrect source");
       FileIO.WriteLn(FileIO.StdOut);
       exit(1)

+ 4 - 0
compiler/tests/sep_empty.def

@@ -0,0 +1,4 @@
+DEFINITION MODULE SepEmpty;
+(* Library without a BEGIN body: its object still exports SepEmpty_init. *)
+PROCEDURE Answer () : INTEGER;
+END SepEmpty.

+ 4 - 0
compiler/tests/sep_empty.mod

@@ -0,0 +1,4 @@
+IMPLEMENTATION MODULE SepEmpty;
+PROCEDURE Answer () : INTEGER;
+BEGIN RETURN 42 END Answer;
+END SepEmpty.

+ 6 - 0
compiler/tests/sep_empty_prog.mod

@@ -0,0 +1,6 @@
+MODULE SepEmptyProg;
+FROM SepEmpty IMPORT Answer;
+VAR ExitCode : INTEGER;
+BEGIN
+  IF Answer() = 42 THEN ExitCode := 42 ELSE ExitCode := 1 END
+END SepEmptyProg.

+ 6 - 0
compiler/tests/sep_lib.def

@@ -0,0 +1,6 @@
+DEFINITION MODULE SepLib;
+(* Separate-compilation test library: exported data + procedures. *)
+VAR Value : INTEGER;
+PROCEDURE Add (x : INTEGER);
+PROCEDURE Get () : INTEGER;
+END SepLib.

+ 7 - 0
compiler/tests/sep_lib.mod

@@ -0,0 +1,7 @@
+IMPLEMENTATION MODULE SepLib;
+PROCEDURE Add (x : INTEGER);
+BEGIN Value := Value + x END Add;
+PROCEDURE Get () : INTEGER;
+BEGIN RETURN Value END Get;
+BEGIN Value := 40
+END SepLib.

+ 8 - 0
compiler/tests/sep_prog.mod

@@ -0,0 +1,8 @@
+MODULE SepProg;
+(* Separate compilation: link with sep_lib.o, never with sep_lib.mod. *)
+FROM SepLib IMPORT Value, Add;
+VAR ExitCode : INTEGER;
+BEGIN
+  Add(2);
+  IF Value = 42 THEN ExitCode := 42 ELSE ExitCode := 1 END
+END SepProg.

+ 5 - 3
docs/INDEX.md

@@ -14,6 +14,8 @@ bisecting *why* a decision was made, not for reading end-to-end).
   ASTDUMP root cause).
 - **`git-branches-tags.md`** — branch/tag map (which refs are current,
   historical, or moving).
+- **`separate-compilation.md`** — implemented `-c`/per-object workflow,
+  stable symbols and remaining limits.
 - **`library-folders.md`** — dialect library layout and selection.
 - **`STATUS.md`** — current state, architecture, conformance policy,
   what remains.
@@ -37,9 +39,9 @@ bisecting *why* a decision was made, not for reading end-to-end).
 - `plan-resolve-check.md` — the resolve/check thread (now concluded; the
   pass was deleted).
 - `plan-lower.md` — the `Lower` emitter.
-- **`plan-separate-compilation.md`** — future work: stable symbols,
-  per-unit objects, init order, linker, and the dialect library-folder
-  layout (deferred until the compiler is finished).
+- **`plan-separate-compilation.md`** — design record: core stable
+  symbols/per-unit objects/init/linker implemented; deferred generics,
+  incremental builds and self-host; dialect library-folder appendix.
 - **`design-exceptions.md`** — ISO exceptions design: grammar/AST,
   handler stack, `sigsetjmp`/`siglongjmp` codegen, `EXCEPTIONS` /
   `M2EXCEPTION`, trap mapping, module `FINALLY`, tests.  M1–M5 and

+ 12 - 5
docs/STATUS.md

@@ -1,12 +1,12 @@
-# m2compiler-V3 — status (2026-10-23)
+# m2compiler-V3 — status (2026-10-24)
 
 This answers "where are we now?".
 
 ## Numbers
 
-- Suite **301/301** (`compiler/run_tests.sh`).
+- Suite **303/303** (`compiler/run_tests.sh`).
 - Self-hosting **FIXPOINT OK** — stage2 and stage3 images are
-  byte-identical (3,573,487 bytes).
+  byte-identical (3,717,887 bytes).
 - Corpus **519 / 612** sources compile with 0 errors, **0 crashes**
   (`tools/v3-corpus/corpus.sh` over the gm2 testsuite; the harness now
   resolves V3's own shipped libraries); the rest are genuine language
@@ -14,7 +14,7 @@ This answers "where are we now?".
   imports of gm2-only modules.  Open-array corpus cases `unbounded`,
   `unbounded2`, `unbounded3`, `unbounded4` and `array9` now compile;
   `unbounded`/`unbounded2`/`unbounded3`/`unbounded4` also run.
-- Branch **`master`**, feature state **`v3-open-arrays`**; tree
+- Branch **`master`**, feature state **`v3-separate-compilation`**; tree
   clean.
 
 ## Architecture (current)
@@ -25,6 +25,7 @@ per unit:
              re-parsed at unit end, so forward references bind normally
   Lower   -> walk the AST, emit QBE (the only emitter)
   Emit    -> buffered image, EndModule
+            (one image = one object in -c mode)
 ```
 
 - **Two-phase parse.**  Procedure bodies are deferred (module level) and
@@ -197,7 +198,13 @@ declarations still work (`t_fwdproc`, `t_fwdok`).
    `t_manyproc`), the label/`GOTO` tables, the 32-actual AST buffer and
    `SymTab`'s `MaxTypes` all report `230` (implementation limit) instead
    of truncating silently.  Still weak: error recovery and message polish.
-4. **Library folders** — `stdlib/` is retired; libraries live in
+4. **Separate compilation** — `M2 -c` emits per-unit objects (libraries
+   have no `main`), stable module/class symbols, `export data`, and
+   dependency-ordered init calls.  `M2make --separate` resolves
+   interfaces and links objects.  Core shipped; generics, incremental
+   builds and an `M2 -I` scanner remain.  See
+   `docs/separate-compilation.md`.
+5. **Library folders** — `stdlib/` is retired; libraries live in
    `lib/pim`, `lib/iso`, `lib/common` and `lib/gm2`, with the shared
    runtime in `runtime/syslib`.  M2make selects a dialect with
    `--dialect pim|iso|gm2`; the corpus selects from its input path.

+ 18 - 8
docs/handoff-2026-10-23-next.md

@@ -155,10 +155,19 @@ separate compilation.
   bound only, as now);
 - trap tests for CHAR/enum subranges (currently integer).
 
-## 5. Separate compilation — deferred
-
-- `docs/plan-separate-compilation.md` — stable symbols, per-unit
-  objects, init order, linker.
+## 5. Separate compilation — core done (2026-10-24)
+
+- `M2 -c` emits one object per unit; libraries have no `main`; every
+  object exports `<Module>_init`; the program object calls imported
+  inits in dependency order.
+- `M2make --separate` resolves `.def` interfaces, compiles per-module
+  objects and links with `cc`.
+- Stable symbols: `<Module>__<proc>`, `<Module>_<global>`,
+  module-qualified pools, `<Module>__<Class>__vt`; all top-level data is
+  `export data`.
+- See `docs/separate-compilation.md`.  Deferred: `M2 -I` import
+  scanner, incremental builds, generic-object emission, ABI marker,
+  and self-hosting via per-object builds.
 - Library folders are **done**: `lib/pim`, `lib/iso`, `lib/common`,
   `lib/gm2` plus shared `runtime/syslib`; selection is via
   `M2make --dialect` or the corpus path.  See `docs/library-folders.md`.
@@ -193,7 +202,8 @@ cd .. && TMP="$PWD/.scratch/fixpoint" ./bootstrap/fixpoint.sh
 tools/v3-corpus/corpus.sh                            # 512 OK, 0 crash
 ```
 
-**Recommendation:** start **separate compilation**
-(`docs/plan-separate-compilation.md`).  Library folders are done
-(`docs/library-folders.md`); use one dialect per session.  The
-one-shot multi-file mode remains the default for tests/corpus.
+**Recommendation:** fill the remaining separate-compilation work
+(`M2 -I` import scanning, incremental objects, generic instances,
+per-object self-host).  Library folders are done; use one dialect per
+session.  The one-shot multi-file mode remains the default for
+tests/corpus.

+ 38 - 19
docs/plan-separate-compilation.md

@@ -1,11 +1,12 @@
-# Plan — separate compilation for V3 (future work)
+# Plan — separate compilation for V3
 
-**Status: deferred.**  Finish the compiler as it is first (corpus,
-language gaps, robustness).  This document records the design so the
-work can be picked up later without re-deriving it.
+**Status: core implemented (2026-10-24).**  See
+`docs/separate-compilation.md` for the shipped workflow.  The original
+one-shot mode remains the default for the suite/corpus; this document
+keeps the design record and the deferred phases.
 
-**Baseline at the time of writing:** `v3-smallfix`; suite 259/259,
-fixpoint OK 4,028,447 bytes, corpus 511/612, 0 crashes.
+**Baseline now:** suite 303/303, fixpoint OK 3,717,887 bytes, corpus
+519/612, 0 crashes.
 
 ---
 
@@ -51,7 +52,7 @@ V3 is a single-session, whole-program compiler:
 - Generics re-parse the generic body in the client session (`M2.mod`
   `DoInst`).
 
-## 3. Required design
+## 3. Implemented design (core)
 
 ### 3.1 Stable symbol names (critical path)
 
@@ -142,22 +143,23 @@ cc Prog.o Lib.o runtime/syslib/shim.o -lm -o Prog
 - Runtime (`shim.c`, `-lm`) is linked once, as today.
 - No linker script needed initially.
 
-## 5. Phases (do not start until the current compiler work stops)
+## 5. Phases
 
 1. **Stable names + per-unit `.ssa` in one session.**  Keep the
    whole-session driver, emit one `.ssa` per unit, link them.  Validates
    symbols, pools, vtables and the init list.
-2. **`-c` / `-I` driver mode.**  Compile one `.mod` against transitive
-   `.def`s; emit `.o`; link with `cc`.
-3. **Init order + program/main split.**  Generated init table, module
-   bodies, nested modules.
-4. **Generics and classes.**  Instance emission strategy; stable
-   vtable/method symbols.
-5. **M2make incremental builds.**  Per-module `.o` targets, `.def`
-   dependencies, relink.
-6. **Self-host with separate compilation.**  The compiler as N objects;
-   adjust the fixpoint gate (compare per-object `.ssa` or the linked
-   binary).
+2. **`-c` / `-I` driver mode.**  DONE for `M2 -c` with explicit
+   interfaces and a sibling target `.def`; M2make `--separate` supplies
+   transitive `.def` resolution.  `M2 -I` itself is still deferred.
+3. **Init order + program/main split.**  DONE: library objects have no
+   main, every object exports `<mod>_init`, and the program object calls
+   imported inits in dependency order.
+4. **Generics and classes.**  PARTIAL: class vtable/method symbols are
+   stable across objects; generic instances are still client-local.
+5. **M2make incremental builds.**  DEFERRED: `--separate` rebuilds all
+   objects.
+6. **Self-host with separate compilation.**  DEFERRED: the fixpoint
+   still compares the one-shot compiler image.
 
 ## 6. Tests / gates for each phase
 
@@ -195,6 +197,23 @@ cc Prog.o Lib.o runtime/syslib/shim.o -lm -o Prog
 - Keep `.def` as the source of truth; a `.sym` cache is optional.
 - Keep the one-shot mode for the test suite/corpus.
 
+## Implemented in the core pass
+
+- Module-level procedure symbols are `<module>__<name>`; globals remain
+  `<module>_<name>`; string/constructor/set pools are module-qualified;
+  class vtables are `<module>__<class>__vt`.
+- `QbeGen` has library (`no main`) and program modes, an external init
+  list, and exports all top-level data.
+- `M2 -c` lowers the target `.mod` (and its own `.def` for exported
+  data), skips imported definition units, and emits
+  `gen_ssa/<module>.ssa`; target `Foo.mod` auto-finds `Foo.def`.
+- `M2make --separate` compiles each module against the ordered
+  interfaces, writes `gen_ssa/<Module>.o`, and links with `cc`.
+- `Lower` emits an empty `<module>_init` for bodyless library objects
+  so cross-object init calls always resolve.
+- Tests: `SepProg`, `SepEmptyProg`, plus a manual cross-object class
+  check.
+
 ## Appendix — related future work: dialect library folders
 
 The question “can PIM, M2R10 and ISO libraries live in separate

+ 94 - 0
docs/separate-compilation.md

@@ -0,0 +1,94 @@
+# Separate compilation — implementation note
+
+**Status: core shipped (2026-10-24).**
+
+V3 now supports per-unit objects.  The original one-shot multi-file
+mode is still the default for the test suite and corpus; separate
+compilation is additive.
+
+## Workflow
+
+The compiler itself emits QBE IR, one unit at a time.  `M2make
+--separate` drives the whole object build and link:
+
+```sh
+M2make --separate --dialect iso \
+       --m2bin ./compiler/M2 --shim runtime/syslib/shim.c \
+       -o Prog Prog.mod
+```
+
+It resolves the imported `.def` files transitively, compiles each
+`.mod` against interfaces only, writes objects to `gen_ssa/<Module>.o`,
+and links them with `cc` plus the shared runtime shim.  No `.def` is
+passed to `cc`.
+
+For a single unit, `M2 -c` emits QBE IR directly:
+
+```sh
+M2 -c Lib.mod                       # picks up Lib.def automatically
+M2 -c Lib.def Prog.mod              # explicit imported interface
+qbe -o gen_ssa/Lib.s gen_ssa/Lib.ssa
+cc -c gen_ssa/Lib.s -o Lib.o
+```
+
+`M2 -c` requires the imported interfaces on the command line (except
+the target's own `.def`, which is found beside the `.mod`).  M2make is
+the transitive-interface resolver.
+
+## Object model
+
+- **Library objects** contain declarations and bodies for one module
+  and **no `main`**.  Every library object exports `<Module>_init`,
+  even when the module has no `BEGIN` body.
+- **The program object** is the only object with `main`; it calls all
+  imported module inits in dependency order, then the program's own
+  init, then returns `ExitCode`.
+- Module-level procedures use stable symbols `<Module>__<name>`.
+  Globals stay `<Module>_<name>`; literals, constructors and sets are
+  module-qualified (`<Module>_strN`, `<Module>_ctoN`,
+  `<Module>_setN`); class vtables are `<Module>__<Class>__vt`.
+- All top-level data is emitted with QBE `export data`, so a program
+  object can reference an imported module's exported variable.
+- Nested procedures remain object-local (module prefix + uid).
+- Module init order comes from the interface order; M2make's
+  topological sort supplies dependencies first.
+
+The implementation is split across:
+
+| Area | Change |
+| --- | --- |
+| `SymTab` | uid → module/depth/class tables; stable type/class names |
+| `QbeGen` | stable `Mangled`, module-qualified pools, `export data`, library/program split, external init list |
+| `Lower` | `-c` mode, empty init for bodyless objects, target `.def` data emission, class/vtable stability |
+| `compiler.frm` / `M2.mod` | `-c`, sibling `.def` lookup, imported-def init list, target def matching |
+| `M2make` | `--separate`: per-module `M2 -c` + qbe + cc, final link |
+
+## Limits / next phases
+
+- **No `M2 -I` import scanner yet.**  `M2 -c` uses explicit interfaces;
+  use `M2make --separate` for transitive resolution.
+- **No incremental build:** `--separate` rebuilds every object.  Stale
+  objects are not detected; a `.def`/object mismatch surfaces as a
+  normal linker error.
+- **Generics** still need the generic implementation source at the
+  client; separate generic instances are not emitted as their own
+  objects yet.
+- **Classes** have stable vtable/method symbols for normal imported
+  class use; a class that exists only in a `.def` (no implementation
+  object) must still have its vtable materialised by a user object.
+- **ABI/version marker** in objects is not implemented.
+- The fixpoint gate still compares the one-shot compiler image, not a
+  multi-object rebuild.
+
+## Tests
+
+`compiler/run_tests.sh` has separate-mode gates:
+
+- `SepProg` — exported data, procedure call, library `BEGIN`, link and
+  run (`42`), library object must not define `main`.
+- `SepEmptyProg` — a library with no `BEGIN` still exports
+  `SepEmpty_init` and links (`42`).
+
+Manual class check: a class in `CLib.def/.mod` linked into `CProg`
+returns `42` through a virtual call, with vtable/method symbols
+resolved across objects.

+ 97 - 2
tools/m2make/M2make.mod

@@ -46,7 +46,7 @@ VAR
   childPos: ARRAY [0..255] OF CARDINAL;
   order: ARRAY [0..255] OF CARDINAL;
   nOrder: CARDINAL;
-  dryRun, verbose, useGm2: BOOLEAN;
+  dryRun, verbose, useGm2, separate: BOOLEAN;
   outGiven: BOOLEAN;
   mainPath, outName, m2bin, shimPath: Path;
   m2Flags: Cmd;
@@ -284,7 +284,7 @@ PROCEDURE Usage;
 BEGIN
   Out("Usage: M2make [-n] [-v] [-c m2|gm2] [-I dir] [-o out]"); OutLn;
   Out("       [--m2bin path] [--shim path] [--extra path]
-       [--m2flag flag] [--dialect pim|iso|gm2] main.mod"); OutLn
+       [--m2flag flag] [--dialect pim|iso|gm2] [--separate] main.mod"); OutLn
 END Usage;
 
 (* ---------------- module table ---------------- *)
@@ -830,6 +830,98 @@ BEGIN
   END
 END BuildM2;
 
+PROCEDURE SepObjName(m: CARDINAL; VAR obj: Path);
+(* One object per module in gen_ssa/, keyed by module name. *)
+BEGIN
+  Zero(obj);
+  GetName(m, tmpN);
+  Cat("gen_ssa/", tmpN, tmpA);
+  Cat(tmpA, ".o", obj)
+END SepObjName;
+
+PROCEDURE BuildM2Separate(out: ARRAY OF CHAR);
+(* Compile every module to its own QBE object, then link.  The program
+   module is last in the topological order and is the only object with
+   main; every module object exports <mod>_init. *)
+VAR cmd: Cmd; k, m: CARDINAL; rc: INTEGER;
+    obj, src, ssafile, sfile: Path;
+BEGIN
+  Zero(cmd);
+  k := 0;
+  WHILE k < nOrder DO
+    m := order[k];
+    GetSrc(m, src);
+    IF Len(src) > 0 THEN
+      IF modIsMain[m] THEN Copy(progName, tmpN) ELSE GetName(m, tmpN) END;
+      Zero(obj); Cat("gen_ssa/", tmpN, tmpA); Cat(tmpA, ".o", obj);
+      CpyPath(ssafile, "gen_ssa/"); Cat(ssafile, tmpN, tmpA); Cat(tmpA, ".ssa", ssafile);
+      CpyPath(sfile, "gen_ssa/"); Cat(sfile, tmpN, tmpA); Cat(tmpA, ".s", sfile);
+      Zero(tmpCmd);
+      AppendSeg(tmpCmd, "mkdir -p gen_ssa && ");
+      AppendSeg(tmpCmd, m2bin);
+      AppendSeg(tmpCmd, " -c");
+      IF Len(m2Flags) > 0 THEN
+        AppendSeg(tmpCmd, " ");
+        AppendSeg(tmpCmd, m2Flags)
+      END;
+      (* all interfaces, in dependency order, then this object *)
+      m := 0;
+      WHILE m < nOrder DO
+        GetDef(order[m], tmpP);
+        IF Len(tmpP) > 0 THEN AppendTok(tmpCmd, tmpP) END;
+        INC(m)
+      END;
+      AppendTok(tmpCmd, src);
+      AppendSeg(tmpCmd, " 2>&1 | tee m2make.log | grep -q 'Parsed correctly'");
+      AppendSeg(tmpCmd, " && qbe -o ");
+      AppendTok(tmpCmd, sfile);
+      AppendSeg(tmpCmd, " ");
+      AppendTok(tmpCmd, ssafile);
+      AppendSeg(tmpCmd, " && cc -c ");
+      AppendTok(tmpCmd, sfile);
+      AppendSeg(tmpCmd, " -o ");
+      AppendTok(tmpCmd, obj);
+      AppendCmd(cmd, tmpCmd)
+    END;
+    INC(k)
+  END;
+  (* link: one object per module, target object (main) last *)
+  Zero(tmpCmd);
+  AppendSeg(tmpCmd, "cc ");
+  k := 0;
+  WHILE k < nOrder DO
+    m := order[k];
+    GetSrc(m, src);
+    IF Len(src) > 0 THEN
+      IF modIsMain[m] THEN Copy(progName, tmpN) ELSE GetName(m, tmpN) END;
+      Zero(obj); Cat("gen_ssa/", tmpN, tmpA); Cat(tmpA, ".o", obj);
+      AppendTok(tmpCmd, obj)
+    END;
+    INC(k)
+  END;
+  AppendTok(tmpCmd, shimPath);
+  IF Len(extraC) > 0 THEN AppendTok(tmpCmd, extraC) END;
+  AppendSeg(tmpCmd, " -o ");
+  AppendTok(tmpCmd, out);
+  AppendSeg(tmpCmd, " -lm");
+  AppendCmd(cmd, tmpCmd);
+  CheckLen(cmd);
+  Out(cmd);
+  OutLn;
+  IF dryRun THEN RETURN END;
+  rc := M2makeOS.ExecCmd(cmd);
+  IF rc # 0 THEN
+    Out("M2make: separate build failed");
+    OutLn;
+    M2makeOS.ExitNow(1)
+  END
+END BuildM2Separate;
+
+PROCEDURE CpyPath(VAR dst: Path; src: ARRAY OF CHAR);
+BEGIN
+  Copy(src, dst)
+END CpyPath;
+
 PROCEDURE BuildGm2(out: ARRAY OF CHAR);
 VAR cmd, seg: Cmd; flags: Path; obj: Path;
     k, m: CARDINAL; rc: INTEGER; outT: LONGINT;
@@ -963,6 +1055,7 @@ BEGIN
   dryRun := FALSE;
   verbose := FALSE;
   useGm2 := FALSE;
+  separate := FALSE;
   outGiven := FALSE;
   Zero(mainPath);
   Zero(outName);
@@ -979,6 +1072,7 @@ BEGIN
     INC(na);
     IF Cmp(s, "-n") = 0 THEN dryRun := TRUE
     ELSIF Cmp(s, "-v") = 0 THEN verbose := TRUE
+    ELSIF Cmp(s, "--separate") = 0 THEN separate := TRUE
     ELSIF Cmp(s, "-c") = 0 THEN
       ok := M2makeOS.GetArg(na, s, HIGH(s) + 1);
       IF NOT ok THEN
@@ -1136,6 +1230,7 @@ BEGIN
     OutLn
   ELSE
     IF useGm2 THEN BuildGm2(outName)
+    ELSIF separate THEN BuildM2Separate(outName)
     ELSE BuildM2(outName)
     END
   END

+ 7 - 2
tools/m2make/README.md

@@ -10,7 +10,7 @@ to do), 1 build failure (incl. import cycles), 2 usage error.
 ```sh
 M2make [-n] [-v] [-c m2|gm2] [-I dir] [-o out]
        [--m2bin path] [--shim path] [--extra path]
-       [--m2flag flag] [--dialect pim|iso|gm2] main.mod
+       [--m2flag flag] [--dialect pim|iso|gm2] [--separate] main.mod
 ```
 
 - `-n`: dry run (print the build order and the command only).
@@ -29,8 +29,13 @@ M2make [-n] [-v] [-c m2|gm2] [-I dir] [-o out]
 - `--dialect pim|iso|gm2`: add `lib/<dialect>`, `lib/common`,
   `lib/gm2` and `runtime/syslib` to the module search path.  Use one
   dialect per session; do not mix `lib/pim` and `lib/iso`.
+- `--separate`: compile every module to `gen_ssa/<Module>.o` with
+  `M2 -c`, then link the objects with `cc` and the runtime shim.  The
+  program object is the only one with `main`; library objects export
+  `<Module>_init`.  This mode still rebuilds all objects (no
+  incremental `.def`-dependency scan).
 
-`m2` mode runs one session — `<m2bin> <ordered .def/.mod files>`
+`m2` mode (default, one-shot) runs one session — `<m2bin> <ordered .def/.mod files>`
 then `qbe` then `cc <image>.s <shim> [<extra>] -o <out> -lm` — and
 gates on M2's `Parsed correctly` verdict (M2 exits 0 even on
 failure).  Modules the session needs but that are not named on the