← board

An array of const literal does not match once the name is a cross-unit overload set

Found 2026-08-13 while examining whether NilPy's format(v, spec) builtin could be declared in pylib alongside sysutils' Format(fmt, [args]) instead of being intercepted in the parser. It cannot — because of this.

Measured, four rows that isolate it

Two units, each declaring g with overload; uc takes (const fmt: AnsiString; const args: array of const), ud takes (const v: Variant; const spec: AnsiString).

shape result
uses uc; alone — g('%s', ['a']) compiles, prints fmt:%s
both overloads in ONE unit (or the program) compiles, both calls resolve
uses uc, ud; — array-of-const unit FIRST compiles, both calls resolve
uses ud, uc; — array-of-const unit LAST FAILS

The failing diagnostic:

error: no overload of g matches these arguments
  argument types: (ShortString, set)
  candidates:
    g(Variant, AnsiString)
    g(AnsiString, record)

So the merge itself works — both candidates are listed — but the caller-side ['a'] was never turned into an array-of-const constructor: it is still a set literal, and the parameter is reported as record. Something on that path builds the candidate list without telling the argument side that an array of const conversion is available.

Order-dependent, and in the direction that bites us: the failure needs the array-of-const routine's unit to come LAST, which is exactly sysutils' position in a NilPy program (pylib is pulled first, imports after).

Why it matters beyond the one call

It is the reason format had to become a frontend intercept rather than a pylib declaration — see [[decide-nilpy-builtin-vs-pascal-unit-name-resolution]]. Fixing this unblocks the clean route: overload on both declarations makes pxx merge cross-unit sets correctly (proved by row 3), which is the FPC-faithful mechanism for exactly this situation and would generalise to every future collision instead of costing a parser arm per name.

It is also a defect in its own right: any two units that both export a name, one of them with an array of const parameter, hit it — and the failure looks like the CALL is wrong when nothing is wrong with it.

Where to look

MatchCallDelphiProcAddr is the one entry into MatchProcCall* (project_overload_resolution_single_side_channel_entry), and the same-unit path already gets this right, so the question is what the cross-unit candidate walk does differently — the argument is reported as set, which is the type the literal has BEFORE the open-array conversion, so the conversion decision is being made too late or on the wrong list.

Gate

The four rows above as a .pas test (three compile-and-run, one currently failing), Format(fmt, [args]) from sysutils still resolving with and without overload on it, plus self-host byte-identical.

Resolution

The ticket's "where to look" was right: the conversion decision was being made on the wrong list. It was not made too late — it was made too NARROWLY.

ParamIsVarRecArray(procIdx, argNo) asked exactly one candidate: whichever the name resolved to first. The parser has to answer "is this [...] a TVarRec vector or a set literal?" before overload resolution, because the answer determines how the brackets are parsed at all — so with the array-of-const routine in the unit listed last, the question was put to the OTHER overload, answered "no", and the literal was parsed as a set. Overload matching then saw (ShortString, set) and could not match a candidate it had correctly found. That is why the diagnostic listed both candidates: the cross-unit merge was never the problem.

Fix

ParamIsVarRecArray now asks the whole visible overload set, via the same ProcChainHead / ProcHashNext walk the diagnostic's own candidate list uses. The strict single-proc test is preserved as ParamIsVarRecArrayAt, and the walk only runs when the first answer is no and the routine carries overload — so nothing that worked before takes a different path.

Because the six call sites (plain call, method-through-chain, statement position, NilPy argument binding, …) all go through this one function, fixing it fixed all of them at once, rather than adding a seventh place that has to know.

Measured

shape before after
uses uc; alone compiles compiles
both overloads in ONE unit compiles compiles
uses uc, ud; (array-of-const FIRST) compiles compiles
uses ud, uc; (array-of-const LAST) fails compiles, both calls resolve

FPC 3.2.2 accepts the failing program, confirmed by building the same two units and program with {$mode objfpc}: it prints fmt:%s, as pxx now does.

Format(fmt, [args]) from sysutils still resolves in every shape tried — multi-argument, single-argument, empty [], and a Single argument.

The residual corner, escalated not guessed

A SET parameter at the same slot vetoes the reinterpretation, so a genuine set literal is not stolen. But a BRACKET LITERAL where one overload takes set of T and another takes array of const at the same slot has no reference answer to copy: FPC 3.2.2 is itself uses-order dependent there and flips (k-aoc one way, k-set the other), while pxx is order-independent and always reads array of const. Measured both directions in both implementations and filed as [[decide-set-vs-array-of-const-at-the-same-overload-slot]]; the regression test deliberately asserts only the unambiguous form, because pinning the other would cement an accident.

Test

test/test_array_of_const_cross_unit_overload.pas plus two helper units, in the failing uses order, covering one-, two- and zero-element literals, the sibling overload, and the set-parameter case. Wired into the Makefile.

Gate: gate.sh quick GREEN (self-host fixedpoint + --tier quick + FPC seed canary). Parser only, no frozen builtin, so no re-pin.

What this unblocks

The ticket noted this was why NilPy's format had to be a frontend intercept rather than a pylib declaration alongside sysutils' Format ([[decide-nilpy-builtin-vs-pascal-unit-name-resolution]]). That route is now open — not taken here, since it is a separate change with its own gate.

Log