"fix: Restate RFC 0005 coercion as satisfaction then conversion
This change started on the implementation side: we identified that the
coercion code had become too complicated to understand and review properly,
and evaluated how we might improve it. The improvement was to restructure
coercion as two explicitly ordered steps — first check whether the result
already satisfies the target, then convert it if not. Working through that
split surfaced adjustments we wanted to make in the specification itself,
restated here. Because EXPR isn't yet widely deployed in production, we
believe this is still a good time to make a change like this.
The coercion rules applied the scalar rule only "when the target types have a
single scalar type (without counting `nulltype` or `list[T]`)" and the list
rule only when there was a single list type, prescribing no coercion at all
for a target with two or more candidates of the same shape. That leaves
reachable targets undefined: a target built from several candidate signatures
can carry two scalar candidates (`zfill`, `int`, `float`, and `bool` each have
a `float | int | string` parameter position), and implementations coerce there
rather than reporting an ambiguity. The RFC also listed `range_expr` → `string`
and `range_expr` → `list[int]` as rules whose conditions both hold for a
`list[int] | string` target, with no stated winner.
Restate the section in the two steps an implementation actually performs:
- Satisfaction. If the result's type already satisfies the target it is used
unchanged. Spell out the relation, including that a union target needs one
member satisfied and that `list[T]` is covariant in `T`, so `list[int]`
satisfies `list[any]` and `list[int | string]`. Note it is directional and
therefore not the symmetric matching used to bind type variables — using one
for the other accepts a `list[T1]` target by binding `T1` and discarding the
binding — and that a result's type is never itself a union, since union
constraints on unresolved values are decomposed first.
- Conversion. Otherwise convert toward one of the target's destinations, a
union contributing each member, first success winning. This replaces the
single-candidate conditions and makes a union accept at least what each
member accepts on its own.
Destinations are ordered non-list before list, and within each group by a
per-result-type preference table set by two principles: a value prefers to
stay within its own kind, so a number remains a number before it becomes
text, and a conversion that can fail is attempted before one that always
succeeds, since a universal fallback attempted first would make every
destination after it unreachable. So `int` prefers `float` over `string`;
`float` prefers `int` (exact wholes) over `string`; `string` prefers `int`,
then `float`, then the selective `bool` and `range_expr` parses, then `path`,
which every string trivially satisfies; and a list source orders list
destinations by its element type's preference, recursively. This makes the
choice fully deterministic — `5` against `float | string` is `5.0`, `"5"`
against `int | float` is `5` — where a first-draft of this rewrite had left
same-shape order unspecified, letting the same template produce different
jobs on different conforming implementations. The non-list-first level
resolves the `range_expr` overlap: against `list[int] | string` the result is
the canonical string `"1-5"`, whose cost does not depend on the range size.
Add `string` → `bool` (the same case-insensitive spellings as RFC 0006's
explicit `bool()` conversion) and `string` → `range_expr` to the conversion
list. Both are non-destructive parses that succeed only for strings that
unambiguously denote a value of the target type, in the same spirit as
`string` → `int` and `string` → `float`, and they slot directly into the
ordering principles — after the numeric parses, before the universal `path`
fallback — so `"true"` against a `bool | path` target is `Bool(true)`.
Since satisfaction runs first, the conversions no longer need their "when the
target types do not include ..." conditions; those were restating the first
step. State that `nulltype` is never a destination, so a `string` whose text
is `"null"` does not become `null`, and that the type-variable rule holds at
any nesting depth: an implementation must reject a `list` destination whose
element type mentions an unbound type variable rather than binding the
variable and discarding the binding.
Also sharpen the unresolved-value narrowing: against a union target the
constraint narrows to the union of every destination with a type-level rule,
rather than betting on any one of them, because the type level cannot see the
payload that decides which destination wins. The narrowed constraint thus
always satisfies the target and always describes the concrete result — an
`unresolved[float]` narrows to `unresolved[int | string]` against
`int | string`, covering both the 3.0 payload that lands on `int` and the 3.5
payload that falls through to `string`. For a non-union target exactly one
destination exists, so the constraint is exactly the type evaluation will
produce.
Matching user-facing language in the wiki's Expression Language page. The
openjd-rs implementation matches this text, with every stated example pinned
by a test.
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>"