A for-in container must start with an identifier token
compiler/pasparser_stmt.inc:3087:
if CurTok.Kind <> tkIdent then
Error('for-in: expected a generator, enum type, or iterable variable');
Measured 2026-09-07 at compiler 029799e446d5, with
operator enumerator(a: Int64): TEnum in scope, against fpc 3.2.2:
| container | pxx | fpc |
|---|---|---|
v (Int64 variable) |
9 | 9 |
Int64(4) |
4 | 4 |
Integer(4) |
refused | 4 |
4 |
refused | 4 |
(v) |
refused | 9 |
The tell is that Int64(4) works and Integer(4) does not. Same construct,
same operator, same value — the only difference is that Int64 reaches the
parser as tkIdent and Integer as its own token kind. Nothing about the
container's meaning is being consulted; the gate is reading the lexer.
Why it is probably small
The gate guards a dispatch that already ends in a general container-EXPRESSION
path (dyn-array value, string value, set-valued call, and since 2026-09-07 any
value whose type has an operator enumerator). Every shape in the table above
is something that path can already decide — they are refused before reaching it,
not after failing in it. The (v) row is the clean demonstration: v alone
compiles, and one pair of parentheses is the entire difference.
What to check before removing it
The gate predates the expression path and may be carrying a real
disambiguation — for has a counted form too, and for i := 1 to 4 must not be
mistaken for a container. Establish what it was protecting before deleting it
rather than after: if the answer is "nothing any more", say so in the commit,
because a one-line deletion with no recorded reason is what makes the next
reader restore it.
Re-ranked 30 -> 40 (frankS, 2026-09-07, on frank-coord-core's argument)
Filed at 30 on volume -- little real code writes for i in (v). The better axis
is that the discriminator is a lexical accident, so the observable is
ARBITRARY rather than merely wrong: Int64(4) compiles and Integer(4) does
not, for no reason visible in the source, so a user cannot form a rule from it
and cannot predict which spelling of their own cast will build. That is worth
more than its frequency.
Not higher than 40, for the reason CLAUDE.md gives for ranking loud above silent: this refuses to build, so nobody gets a wrong answer from it, and the workaround is to name the value. Its sibling [[bug-p-for-in-over-a-string-prefers-a-user-operator-enumerator-and-fpc-prefers-the-builtin]] stays the one to take first -- that one is silent.
Kept as type: bug, not compat. CLAUDE.md files "FPC accepts a form we
reject" as compat, and this qualifies on its face. But the finding is not that
we lack a dialect feature -- we HAVE this one, and accept it through one
spelling while refusing an equivalent one. The inconsistency is inside our own
rule, which is a bug in it.
Found while fixing
test/test_for_in_operator_enumerator_on_an_alias_and_an_expression.pas, whose
row 4 uses Int64(4) precisely because Integer(4) does not compile.
Fix (2026-09-08, frankS)
The gate is now the block's precondition rather than a refusal:
if CurTok.Kind = tkIdent then
begin
...the seven ident-only arms, unchanged...
end;
{ general container EXPRESSION path — takes what falls through }
ParseExpr;
What it was protecting, since this ticket asked that it be established BEFORE
the change and not after. Every arm between the gate and the expression path
reads CurTok.SVal — FindSym, FindProc (generator), FindTypeAlias (named
subrange), FindSetConst, FindUField (implicit-Self field) — or the selector
token that follows the name. None of them has a meaning for a non-identifier, so
the gate was a real precondition wearing the shape of a diagnostic. Saying so
here because a one-line deletion with no recorded reason is what makes the next
reader restore it.
It was not carrying the counted-for disambiguation the ticket wondered about:
for i := 1 to 4 is decided further down by Expect(tkAssign, ':='), past the
whole for-in dispatch, and the illegal counter variable check sits below that.
Rows, all against fpc 3.2.2
test/test_for_in_operator_enumerator_on_an_alias_and_an_expression.pas
gains three rows and is byte-identical to fpc at 8/8:
| container | before | after |
|---|---|---|
Int64(4) |
1004 | 1004 (control — the spelling that always worked) |
Integer(4) |
refused at the gate | 4 |
4 |
refused at the gate | 4 |
(vv) |
refused at the gate | 9 |
cast and castint are the pair that matters: the SAME cast, differing only in
a token kind, and they now agree with fpc while answering DIFFERENT numbers
(1004 vs 4) — so the operator table is answering on its key rather than
ignoring it. A one-row table cannot tell a correct lookup from a lookup that
ignores its key, and neither can two rows that return the same value.
Positive control on the pre-change compiler: pascal26:73: error: for-in: expected a generator, enum type, or iterable variable.
Residual, measured, and NOT this defect
With the operator declared only on Int64, for i in Integer(4) is still
refused — now by the expression path (not a generator, enum type, or iterable variable) rather than by the gate. fpc takes the Int64 overload for an
Integer operand by assignment compatibility; FindOpOverload matches
(opKind, typeKind, recId) exactly.
That is not a missing rule so much as an unused one: symtab.inc already holds
OpConvSourceRank, whose header records fpc's measured ladder ("exact kind,
else same SIGNEDNESS, else any integer") — and it is consulted by CONVERSION
operators and not by this lookup. Recorded on
[[bug-p-for-in-over-a-string-prefers-a-user-operator-enumerator-and-fpc-prefers-the-builtin]],
which has to grow a ranked lookup anyway and should grow one ranked lookup
rather than two.
Log
- 2026-09-08 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 9ca01a68e.