v-TP3-STRCAT.md 9.6 KB

v-TP3-STRCAT — Session summary

Tag: v-TP3-STRCAT Commit: (the commit that adds this file) Covers: v-TP3-STRCAT

This is the resume point for the session that completed "Next work" item 1c — string concatenation + — in full, with the test harness extensions that the change forced. Every claim below is pinned by fixtures in shell/tests/fixtures/expected.tsv and by the gate suite.

What changed

  1. String concatenation — a + b, writeln(a + b), s := a + b (v-TP3-STRCAT).

TP3's xconcat (TPSRC2:963) builds the result in a scratch string, copies the left operand into it, then appends the right (addstr/addch). The compiler now does the same:

  • StrConcat allocates a fresh 256-byte global scratch STRING per + in the source (NewScratchStr: @@SC of length 256, created at dc; dc += 256). The scratch is a KVar symbol of class TString (size 256).
  • The result ERes becomes a string variable (kind 1), pointing at that scratch (res.idx := dest). This matches how Assignment, IoCall, ParseCmp already handle string operands; no new ERes kind was added.
  • For the left operand: EmPushStrOperand (left) → for TString calls EmPushStrAddr; for a CHAR (one-character literal, char variable, or s[i] element) it builds a two-byte <len><char> on the stack in a per-operand 2-byte scratch slot (dc += 2) and pushes its address. This is how TP3 allows s + 'a' to be legal while s = 'a' is refused.
  • Emit: strstore(dest, left, 255) (existing selector 17) copies left into the scratch. Then strcat(dest, right, 255) (new selector 19) appends right. Both share the same argument order as strstore (dest at [BP+4], src at [BP+6], CX = maximum).
  • Overflow: when the new length > 255, strcat branches to concerr and leaves through INT 21h AH=4Ch with AL=16 (TP3's "string too long"). The exit code is now asserted by the execution harness via a per-fixture .exit sidecar (default 0).
  1. The type rule for string +.

The hook at the head of BinOpEmit was originally:

   IF (left.cls = TString) OR (right.cls = TString) THEN
      StrConcat (...)
   END

That sent 'a' + 'b' down the integer path (both are TScalar+chr, cls != TString) producing CHR(195) — a silently wrong program, not an error. It now tests the same StrOperand predicate that StrConcat guards with:

   IF (StrOperand (left) OR StrOperand (right)) THEN
      StrConcat (...)
   END

where StrOperand(r) := (r.cls = TString) OR (r.cls = TChar) OR ((r.cls = TScalar) AND r.chr). This is a forward-declared function next to StrConcat (added at line 1916). With this hook, two CHARs, or a string plus a number, reach StrConcat and are rejected by its guard as ETypeErr 56. t53_strcat_type ('a' + 'b') pins the refusal; it was red before the hook change (ran through integer path) and is green after.

StrConcat's error path now leaves a well-formed non-string placeholder (res.kind = 0, res.cls = TScalar, res.imm = 0) rather than a half-built kind-2/TString with an uninitialised idx. That is the invariant: if Err() was called (and abortFac set), the returned ERes must not claim to be a string, because string consumers dereference symtab[res.idx] for any TString.

  1. Guard against using failed expression results.

The compiler's idiom is: after ParseExpr, check IF NOT OK () THEN RETURN END before dereferencing any symbol from the returned ERes. That guard was missing in IoCall's argument loop: when a failed concat returned (kind-2/ TString, idx garbage) the write arm did EmPushVarAddr (symtab [arg.idx].local, symtab [arg.idx].off). With the hook now routing error cases into StrConcat, writeln ('a' + 'b') became a compiler segfault. Adding the OK () guard after ParseExpr (arg) in IoCall (mirroring ParseSub) stops consuming a failed argument and lets the statement loop terminate via abortFac. With the safe placeholder in StrConcat, nested cases such as ('a' + 'b') + s also report the first error cleanly.

  1. Runtime: strcat (selector 19).

Runtime.mod gains EmitStrCat which appends the source string to the destination. The implementation preserves AX,BX,CX,DX,SI,DI (pushes all six) because the compiler may hold a string element address in DI across the call (e.g. s := s + s[i] in effect, or expressions that push next operands after a string operation). The path is:

  • check CX (max) <= 255
  • load lenD from [BP+4] (destination length byte), lenS from [BP+6]
  • newLen = lenD + lenS; if newLen > CX → concerr (exit 16)
  • else copy lenS chars from src[1].. to dst[1 + lenD].., set dst[0] = newLen, return
  • concerr: MOV AL,16H; MOV AH,4CH; INT 21H (6 bytes)

The runtime's entry table grows to 20 entries; RT_Entry/RT_Build register entry 19 as strcat. RtProbe.mod extends the name table to index 19. check_runtime.py and runtime.golden were reblessed accordingly.

  1. Safety guards: HALT on full label/fixup tables.

Runtime.mod's MaxLbl was 64 → 96, MaxFix 400 → 512. The procedures M (add a label) and AddFix (add a fixup) now call HALT when full instead of silently dropping, so a blown table produces a clean abort rather than a subtly wrong runtime blob.

  1. Boot sector output: use SI as cursor.

bootcom.s's INT 21h AH=09h handler kept its cursor at 0702h (the first byte of the loaded program image at 0602h in the load window), so writing a CRLF overwrote the start of the loaded code (turning E8 1A into a wild jump). The handler now uses SI as its cursor and only writes to the video buffer via ES/DI; this removes the code-window limit and fixes the hang seen during bring-up.

  1. Execution harness: per-fixture exit codes.

tests/run_com_exec.py now reads a .exit sidecar (decimal integer, default 0) per fixture (stem + ".exit") via cases(), and asserts exit_code_of(rc) == c["exit"] after the output comparison. The module docstring documents the convention. This is how t55_strcat_overflow asserts exit code 16 while still checking its partial output (read\r\n).

Tests

New fixtures:

  • t51_strcat.pas — all operand pairings (var+var, literal+var, var+literal, var+char, char+var, nested concatenation in arguments/assignment), covering multiple consumers (writeln, assignment, comparison context). Output committed byte-for-byte.
  • t52_strcat_nest.pas — nested a + b + '!', concatenation compared to a string, and a string element as a + operand. Output committed.
  • t53_strcat_type.pas — error: 'a' + 'b' (two CHARs). Expected ERR 56 663.
  • t54_strcat_strnum.pas — error: s + 1 (string + number). Expected ERR 56 463.
  • t55_strcat_overflow.pas — readln 128 characters, then s := s + s (length becomes 256, >255) → runtime exit 16. Input 128×x+LF in .in, output read\r\n in .out, .exit 16.

expected.tsv rows for t51–t55 with their verdicts, code/data bytes, and error positions (the pos is the 0-based offset of the lookahead token when Err fires).

Gates: run_compile_tests.sh 56/56; run_com_tests.sh 48/48; run_com_exec.py 48/48 (t55 has exit 16 asserted); rt_exec.py 56/56; check_runtime.py PASS; check_8086.py PASS; nonvacuity.sh 78 ok/0 failed; run_all.sh OVERALL ALL PASS.

How to verify

cd shell && make                               # tpshell rebuilt, clean
cd shell && tests/run_compile_tests.sh         # 56 passed, 0 failed
cd shell && tests/run_com_exec.py              # 48 passed, 0 failed
cd shell && tests/rt_exec.py                   # 56 passed, 0 failed
cd shell && tests/check_runtime.py             # PASS
cd shell && tests/check_8086.py                # PASS
cd shell && tests/nonvacuity.sh                # 78 ok, 0 failed
cd shell && tests/run_all.sh                   # OVERALL: ALL PASS

At this tag the suite reports:

compile matrix: 56 passed, 0 failed (of 56)
independent .COM check: 48 checked, 0 failed
execution: 48 passed, 0 failed (of 48) (t55 asserts exit 16 via .exit)
exec86:    48 passed, 0 failed (of 48), cross-checked against qemu
runtime entries: 56 passed, 0 failed (of 56) (55 cases + BP pre-flight)
runtime: 770 bytes, code 0..738 (739), 20 entries, 65 branches checked
check_8086: runtime 770/420, program code 48/48 (9692 bytes), clause H 11/11
nonvacuity: 78 ok, 0 failed
OVERALL: ALL PASS

Notes

  • The -fgen-module-list ... || true link step prints a roll-up error from Compiler.mod during the final link of tpshell; this is the expected pass-3 summary and does not indicate a compilation failure (Compiler.o was built successfully).
  • compiletest is rebuilt when its inputs are newer than the binary (the harness checks staleness). After modifying Compiler.mod, run_compile_tests.sh will rebuild compiletest.
  • The commit message for the code+test changes should name: the bootcom.s AH=09h cursor fix (use SI), the Runtime.mod MaxLbl/MaxFix HALT guards and the table increases, the BinOpEmit hook change to use StrOperand (fixing the 'a' + 'b' silent wrong program), the IoCall OK() guard and the StrConcat safe-placeholder fix (eliminating the compiler segfault), the new runtime entry strcat (selector 19) and register preservation, the run_com_exec.py .exit sidecar convention, and the new fixtures t51–t55.