diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 45329d077..c37eba707 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -630,6 +630,41 @@ export function checkDefaultParamBodyType( } } +/** Whether a lambda's OWN signature maps to its completed ABI position for + * position — the contract its reads carry (mapType spells `x?: T` as the + * `T | undefined` slot). Two positions can escape the mapped-TYPE + * comparison in requireExactArityValue: a parameter the checker spells + * `any` holds the checked-dynamic value that mapType leaves unmapped when + * its spelling is context-free, and a *contextually* typed lambda's own + * signature need not be the slot's (a comparator with a surplus optional + * parameter under a two-parameter slot). */ +function lambdaSpellsCompletedAbi( + lowerer: Lowerer, + lambda: ts.ArrowFunction | ts.FunctionExpression, + funcType: IrType, +): boolean { + if (funcType.kind !== "func" || funcType.rest === true) return false; + const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(lambda)); + if (sigs.length !== 1) return false; + const sig = sigs[0]!; + const params = sig.getParameters(); + if (params.length !== funcType.params.length) return false; + for (let i = 0; i < params.length; i++) { + const pTs = lowerer.checker.getTypeOfSymbol(params[i]!); + const slot = funcType.params[i]!; + if ((pTs.flags & ts.TypeFlags.Any) !== 0) { + if (slot.kind !== "dyn") return false; + continue; + } + const mappedParam = lowerer.mapTypeOf(pTs); + if (!mappedParam || !typeEquals(mappedParam, slot)) return false; + } + const retTs = lowerer.checker.getReturnTypeOfSignature(sig); + if ((retTs.flags & ts.TypeFlags.Any) !== 0) return funcType.ret.kind === "dyn"; + const mappedRet = lowerer.mapTypeOf(retTs); + return mappedRet !== null && typeEquals(mappedRet, funcType.ret); +} + /** DECISION (docs/ir.md): function VALUES carry one completed native ABI. * Optional/default parameters appear as literal `T | undefined` slots; * typed rest parameters appear as one trailing typed-array slot plus the @@ -675,6 +710,20 @@ export function requireExactArityValue( ? lowerer.mapTypeOf(lowerer.typeOf(contextual)) : null; if (mapped && typeEquals(mapped, funcType)) return; + // A lambda whose OWN signature already spells the completed ABI is a + // value: every read of it types it that way, and its slot converts it + // (the surplus-parameter and undefined-arm adapters). The mapped-type + // comparison above misses two such lambdas — one whose `any` parameter + // mapType leaves unmapped (`(a: any, b: any, loose?: boolean) => number` + // beside a two-parameter slot), and one whose signature is wider than + // the contextual slot it is written under. + if ( + contextual !== null && + (ts.isArrowFunction(contextual) || ts.isFunctionExpression(contextual)) && + lambdaSpellsCompletedAbi(lowerer, contextual, funcType) + ) { + return; + } // An 'any' slot (`C.prototype.m = function (a, b) {…}` through an // untyped receiver), or a slot whose type maps to a checked-dynamic // value (a member of a literal lowered with a generic function's type diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 44bca35e1..50366eeb6 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -114,10 +114,22 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool return false; } +/** Whether an omitted parameter position completes with the native + * undefined value: a checked-dynamic slot takes undefined as-is, and an + * undefined-armed union (`x?: T`, `x: T = e`) is the completed ABI + * spelling. */ +export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "dyn" || typeEquals(type, UNDEFINED_T)) return true; + return type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0; +} + /** Whether all parameters and the result adapt without a trap-only path. * Rest signatures are excluded, except a typed-rest source in a slot with * fixed parameters (the rest packing adapter); extra destination - * parameters are ignored. */ + * parameters are ignored. A source with MORE parameters than the slot is + * clean when every extra trailing one is omittable — the adapter passes + * that absent value explicitly (funcCoerceAdapter's omittedParams), the + * same way a call through the slot would. */ export function cleanFuncAdaptable( lowerer: Lowerer, src: IrType & { kind: "func" }, @@ -125,8 +137,10 @@ export function cleanFuncAdaptable( ): boolean { if (restPackingAdapterFits(lowerer, src, dst)) return true; if (src.rest === true || dst.rest === true) return false; - if (src.params.length > dst.params.length) return false; - for (let i = 0; i < src.params.length; i++) { + for (let i = dst.params.length; i < src.params.length; i++) { + if (!omittableTrailingParam(lowerer, src.params[i]!)) return false; + } + for (let i = 0; i < src.params.length && i < dst.params.length; i++) { if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false; } if (dst.ret.kind === "void") return true; diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 71d3d0876..1224bce3e 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -70,6 +70,7 @@ import { import { dynamicReceiverThrows } from "./dynamic-receivers.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; import { checkedFilterNarrowing } from "./filter-predicates.js"; +import { omittableTrailingParam } from "../coercions/function-adapters.js"; /** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct * call of a synthetic module function — one per method + element/result @@ -2657,11 +2658,15 @@ export function lowerArraySortCall( let fnArg = undefinedArg ?? lowerOptionalElementComparator(lowerer, argNode, unwrappedArg, elem, arrT); // Reusable JS comparators often accept checked values. Adapt the array's - // element ABI just as other array callbacks do before sorting. + // element ABI just as other array callbacks do before sorting. A + // comparator declaring MORE parameters than sort passes (`(a, b, loose?)`, + // the imported semver shape) receives the surplus ones as undefined — the + // completion an ordinary two-argument call performs — so it adapts to the + // (a, b) slot like any other wider signature. if ( fnArg.type.kind === "func" && - fnArg.type.params.length <= 2 && - fnArg.type.params.every((param) => lowerer.coercibleValue(elem, param)) + fnArg.type.params.slice(2).every((param) => omittableTrailingParam(lowerer, param)) && + fnArg.type.params.slice(0, 2).every((param) => lowerer.coercibleValue(elem, param)) ) { // Arithmetic on inferred JS values retains a checked result because // it can produce BigInt. The sort callback still requires a number. @@ -2670,7 +2675,7 @@ export function lowerArraySortCall( ? F64 : fnArg.type.ret; const expected = funcOf( - fnArg.type.params.map(() => elem), + fnArg.type.params.slice(0, 2).map(() => elem), result, ); if (!typeEquals(fnArg.type, expected)) fnArg = lowerer.coerceToExpected(fnArg, expected); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json new file mode 100644 index 000000000..56abd06eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8529-sort-comparator-surplus-params.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8529-sort-comparator-surplus-params.ts b/tests/corpus/8529-sort-comparator-surplus-params.ts new file mode 100644 index 000000000..9b0d0054b --- /dev/null +++ b/tests/corpus/8529-sort-comparator-surplus-params.ts @@ -0,0 +1,42 @@ +// A comparator may declare more parameters than `sort` passes: the surplus +// positions receive the native undefined value, exactly as in a two-argument +// call. The imported `(a, b, loose) => …` comparators and lambdas with an +// optional or defaulted trailing parameter take this shape, and a lambda +// whose own signature already spells its completed ABI is a value wherever +// its slot converts. + +type Comparer = (a: string, b: string, loose?: boolean) => number; + +const seen: string[] = []; +const cmp: Comparer = (a, b, loose) => { + seen.push(loose === undefined ? "absent" : String(loose)); + return a < b ? -1 : a > b ? 1 : 0; +}; + +const versions = ["1.9.0", "1.10.0", "1.2.0"]; +console.log([...versions].sort(cmp).join(",")); +console.log(seen.length > 0 && seen.every((entry) => entry === "absent")); +console.log(versions.join(",")); + +// `toSorted` adapts the same way and leaves the receiver untouched. +console.log(versions.toSorted(cmp).join(","), versions.join(",")); + +// Direct calls through the value complete the same parameters. +console.log(cmp("a", "b"), cmp("b", "a"), cmp("a", "a", true)); + +// An inline comparator with an optional surplus parameter sorts by number. +const nums = [3, 1, 2]; +const descending = (a: number, b: number, dir?: boolean): number => + dir === undefined ? b - a : a - b; +console.log(nums.toSorted(descending).join(",")); +console.log([...nums].sort((a, b, dir?: boolean) => (dir ? a - b : b - a)).join(",")); +console.log(nums.join(",")); + +// A defaulted surplus parameter keeps its default when sort omits it. +const withDefault = (a: string, b: string, scale = 1): number => + (a < b ? -1 : a > b ? 1 : 0) * scale; +console.log(["b", "a", "c"].sort(withDefault).join("")); +console.log(withDefault("a", "b"), withDefault("a", "b", 3)); + +// A one-parameter comparator is still a prefix of the slot. +console.log(["d", "c", "a"].sort((left) => left.charCodeAt(0) - 97).join(",")); diff --git a/tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json b/tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json new file mode 100644 index 000000000..6c25b05fd --- /dev/null +++ b/tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 3700 }