← board

Dynamic-array concatenation

Found 2026-08-22 by an FPC differential sweep over language shapes (fpc -Mobjfpc -O1 3.2.2 vs pxx 3a107f008).

Repro

{$mode objfpc}{$H+}
var a, b, c: array of Integer;
begin
  SetLength(a, 2); a[0] := 1; a[1] := 2;
  SetLength(b, 1); b[0] := 3;
  c := Concat(a, b);                { fpc: 3 1 3 }
  Writeln(Length(c), ' ', c[0], ' ', c[2]);
end.
pascal26: error: arithmetic operator not supported for dynamic arrays
          (`a + b` array concatenation is not implemented)

Both spellings fail, including a + b under {$modeswitch arrayoperators}, and Concat over an array of string as well as array of Integer.

Why this is not a bug

The diagnostic is deliberate (ir.inc ~8423): it replaced a silent miscompile where a + b fell through to the integer/pointer IR_BINOP, ADDED the two array HANDLES and produced a wild pointer — bug-dynarray-concat-silent-miscompile. Refusing honestly was the right first move; implementing the operation is the second.

Shape of the fix

The pieces exist: AN_DYN_COPY builds a fresh dyn array and copies a range into it (feature-copy-intrinsic), and Insert/Delete over a dyn array already resize and shift (feature-dynarray-insert-delete). Concat is SetLength(dest, la + lb) followed by two element-wise copies, with the same managed-element ARC the existing paths use — so route both spellings to one lowering rather than growing two.

Both spellings must reach it: Concat(a, b, ...) (n-ary, as the string intrinsic already is) and the + operator. Follow normalise-dont-special-case.md — one lowering, two entry points — or the second entry point is the one that stays broken.

Cases the test must cover: an empty operand on either side, both empty, a managed element type (array of string, refcounts balanced), a := a + b (the destination aliasing an operand), and three-way Concat(a, b, c).

Gate

make compiler/pascal26 + a test asserting fpc parity on all of the above + tools/gate.sh quick.

Resolution (2026-08-25)

Both spellings implemented, through ONE construction — MakeDynConcat (pasparser_expr.inc, next to MakeWholeDynCopy), called from the Concat intrinsic arm and from ParseSimpleExpr's + arm.

Concat IS the array-splice Insert with the index pinned past the end. PXXDynInsArrFill already clamps its index to [0..len], so a MaxInt index means "append", and AN_DYN_INSERT's whole apparatus — fresh temp, SetLength(temp, 0) then the sizing SetLength, the raw fill, PXXDynArrayRetainImmediate for managed and nested element types, the prologue nil-init — serves unchanged. No second lowering was written, and no second ARC story exists to drift. That is the ticket's own "one lowering, two entry points", taken literally.

The index is a CONSTANT rather than a Length(a) node deliberately: a Length node would re-evaluate the left operand, so Concat(F(), G()) would call F twice. MakeWholeDynCopy pins its count the same way for the same reason.

Shape readers taught that the node IS an array: AN_DYN_INSERT joins AN_DYN_COPY in NodeDynDepth / NodeDynBaseTk / NodeDynBaseRec (ast_arena.inc) and in the twin DynArrayNodeDepth (symtab.inc) — edited together, as that function's own note demands. Without those arms a concat stops being an array the moment it is used as an operand, which is what makes Concat(a, b) + b and Length(Concat(a, b)) work.

Refusals kept honest. -, *, div, and a + with an array on only one side still hit the ir.inc diagnostic (text updated — it no longer claims concatenation is unimplemented). Mismatched element type or nesting is refused in MakeDynConcat with a specific message rather than silently striding wrong; frozen-string elements are refused exactly as dynamic-array Insert refuses them.

Verification

test/test_dynamic_array_concatenation.pas (wired into test-core), 15 rows, .expected is fpc 3.2.2's own output — matched byte for byte on the first run. Covers every case the ticket named: empty on either side, both empty, three-way Concat(a, b, c), a := a + b and a := b + a (destination aliases an operand), u := u + u (BOTH operands alias it), array of string (managed), array of array of Integer (nested handles), a call result as an operand, a concat nested inside a concat, and Length(Concat(a, b)).

Refcount balance measured, not argued: 200,000 iterations of managed + nested concat peak at 392 KB RSS — flat, and no double free.

make compiler/pascal26 converged in one round; tools/gate.sh quick GREEN.

Log