← board

Low(x) / High(x) of an ordinal variable answer 0 and -1

Found 2026-08-22 by an FPC differential sweep over arrays (fpc -Mobjfpc -O1 3.2.2 vs pxx 0b77e2bea). Two defects in the same pair of intrinsics; fixed together because the second one is what led to the first.

The measurement

Every ordinal VARIABLE, no exceptions:

variable fpc pxx before
b: Byte 0 .. 255 0 .. -1
sm: SmallInt -32768 .. 32767 0 .. -1
n: Integer -2147483648 .. 2147483647 0 .. -1
ch: Char 0 .. 255 0 .. -1
bo: Boolean 0 .. 1 0 .. -1
en: TEnum 0 .. 2 0 .. -1
si: TSubInt = 3..7 3 .. 7 0 .. -1
sc: TSubChar = 'a'..'e' 97 .. 101 0 .. -1

And every named ARRAY TYPE:

operand fpc pxx before
Low(TArr) / High(TArr), TArr = array[5..9] of Integer 5 / 9 undefined variable (TArr)
Low(TArr2D) / High(TArr2D), 2-D 0 / 2 (first dim) undefined variable
Low(TDyn) 0 undefined variable
High(TDyn) compile error compile error (agrees)

The array-VARIABLE arms were already correct and stayed correct.

Root cause

0 and -1 is not a random wrong pair. It is Length(x) - 1, reached through the fallback at the bottom of both intrinsics in compiler/pasparser_expr.inc:

else
begin
  node := AllocNode(AN_CALL); ASTIVal[node] := -Ord(tkLength);
  ...  { Length(x) - 1 }
end;

A scalar has no [data-8] length header, so the runtime Length path answers 0 and High answers -1. Low's fallback is the literal else ASTIVal := 0. Both intrinsics had arms for a 1-D static array, an N-D static array and a fixed record-field array — the shapes people had tripped over — and nothing at all for the operand type the intrinsic is most often written against.

That is the interesting part: the wrong answer is a legal empty range, so the canonical loop

for i := Low(x) to High(x) do ...

compiles, runs, does nothing, and reports no error. High(b) used as a mask or an allocation bound was -1 for the same reason. This is the failure mode devdocs/dev/root-cause-over-microfix.md calls the expensive one: not a crash with a location, but a plausible value far from the cause.

The type-name half is a different miss: TryFoldHighLowType and TryConstHighLowValue handled ordinal names, subrange aliases and enum types, and simply had no arm for FindArrayType.

The fix

Two helpers in compiler/pasparser_lval.inc, each called from both places that need it rather than copied — the two TryConst…/TryFold… functions already carry a comment saying they are one concept in two places, so a third copy was not on:

tkOut carries the bound's TYPE back, so the folded literal prints like fpc's: Low(Char) is #0 and not 0, Low(Boolean) is FALSE and not 0, Low(sc) is 'a'. Asserted directly (Low(ch) = #0, Low(bo) = False).

Known remaining divergence, deliberately matched to the variable path

Low/High of a char-indexed array answers the ordinal, not the char: array['a'..'e'] of Integer gives 97/101 where fpc gives 'a'/'e'. That is the array path's divergence and it predates this ticket — the VARIABLE arm has always answered 97. The new type-name arm answers 97 too, on purpose: a type name and a variable of that type disagreeing would be worse than either answer. Filed as bug-a-low-high-of-a-char-indexed-array-answer-the-ordinal, which should fix both arms in one change.

Gate

make compiler/pascal26 (self-host fixedpoint) + tools/gate.sh quick GREEN. Test test/test_low_high_of_ordinal_and_array_type.pas — 45 assertions, output identical to fpc -O1's on the same source, wired into test-core. It also asserts every previously-working arm (array variable, dynamic variable, empty dynamic variable answering -1 legitimately) so the new arms cannot have displaced them.