Skip to content
Merged
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ full policy.
Bool orders `false < true`; value enums order by backing value.
- `array.is_empty(xs: T[]) -> bool` and `dict.is_empty(d: {K:V}) -> bool`,
mirroring `string.is_empty`.
- Context-directed inference of a generic call's type arguments: a type
parameter that appears only in the return type is inferred from the call's
expected type (a `let`/`final` annotation, `return` type, concretely-typed
argument, an assignment whose target has a known type, or an
array-element/dict-value type), so `let xs: int[] = empty_list()` no longer
needs an explicit `[int]`. Explicit type arguments remain supported and take
precedence.

### Changed

Expand Down
140 changes: 140 additions & 0 deletions internal/codegen/context_inference_codegen_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package codegen

import (
"bytes"
"strings"
"testing"

"github.com/mitchellnemitz/wisp/internal/parser"
"github.com/mitchellnemitz/wisp/internal/types"
)

// hasCheckErr parses+checks src and reports whether any checker error message
// contains sub.
func hasCheckErr(t *testing.T, src, sub string) bool {
t.Helper()
prog, err := parser.Parse(src, "test.wisp")
if err != nil {
t.Fatalf("parse: %v", err)
}
info := types.Check(prog)
for _, d := range info.Errors {
if strings.Contains(d.Msg, sub) {
return true
}
}
return false
}

const ctxEmptyList = "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"

// SC-005: a context-inferred call compiles to byte-identical shell as the
// explicit-type-argument form of the same program.
func TestContextInferByteIdenticalToExplicit(t *testing.T) {
inferred := compile(t, ctxEmptyList+
"fn main() -> int {\n let xs: int[] = empty_list()\n print(to_string(length(xs)))\n return 0\n}")
explicit := compile(t, ctxEmptyList+
"fn main() -> int {\n let xs: int[] = empty_list[int]()\n print(to_string(length(xs)))\n return 0\n}")
if !bytes.Equal(inferred, explicit) {
t.Fatalf("context-inferred output differs from explicit-type-argument output\n--- inferred ---\n%s\n--- explicit ---\n%s", inferred, explicit)
}
}

// SC-005 per-position byte-identity: the return, call-argument, assignment,
// array-element, and dict-value positions each compile byte-identically to their
// explicit-type-argument form. Together with TestContextInferByteIdenticalToExplicit
// (the let/final binding position) this directly covers byte-identity for every
// SC-001..SC-004 position; the golden fixture (Task 5) additionally proves the
// dict-value and other positions identical at runtime across all four shells.
func TestContextInferByteIdenticalPerPosition(t *testing.T) {
cases := []struct {
name, inferred, explicit string
}{
{
"return",
ctxEmptyList + "fn make() -> int[] {\n return empty_list()\n}\n" + "fn main() -> int {\n let xs: int[] = make()\n print(to_string(length(xs)))\n return 0\n}",
ctxEmptyList + "fn make() -> int[] {\n return empty_list[int]()\n}\n" + "fn main() -> int {\n let xs: int[] = make()\n print(to_string(length(xs)))\n return 0\n}",
},
{
"callarg",
ctxEmptyList + "fn takes(xs: int[]) -> int {\n return length(xs)\n}\n" + "fn main() -> int {\n print(to_string(takes(empty_list())))\n return 0\n}",
ctxEmptyList + "fn takes(xs: int[]) -> int {\n return length(xs)\n}\n" + "fn main() -> int {\n print(to_string(takes(empty_list[int]())))\n return 0\n}",
},
{
"assign",
ctxEmptyList + "fn main() -> int {\n let xs: int[] = [1]\n xs = empty_list()\n print(to_string(length(xs)))\n return 0\n}",
ctxEmptyList + "fn main() -> int {\n let xs: int[] = [1]\n xs = empty_list[int]()\n print(to_string(length(xs)))\n return 0\n}",
},
{
"array-elem",
ctxEmptyList + "fn main() -> int {\n let grid: int[][] = [empty_list()]\n print(to_string(length(grid)))\n return 0\n}",
ctxEmptyList + "fn main() -> int {\n let grid: int[][] = [empty_list[int]()]\n print(to_string(length(grid)))\n return 0\n}",
},
{
"dict-value",
ctxEmptyList + "fn main() -> int {\n let m: {string: int[]} = {\"k\": empty_list()}\n print(to_string(length(m[\"k\"])))\n return 0\n}",
ctxEmptyList + "fn main() -> int {\n let m: {string: int[]} = {\"k\": empty_list[int]()}\n print(to_string(length(m[\"k\"])))\n return 0\n}",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !bytes.Equal(compile(t, tc.inferred), compile(t, tc.explicit)) {
t.Fatalf("%s: context-inferred output differs from explicit-type-argument output", tc.name)
}
})
}
}

// SC-006: a numeric-bounded return-only param pinned by context is monomorphized
// for the pinned type, byte-identical to the explicit form; and int vs float
// produce DIFFERENT output (proving specialization actually occurred).
const ctxZeros = "fn zeros[T: numeric](n: int) -> T[] {\n let xs: T[] = []\n return xs\n}\n"

func TestContextInferNumericMonoByteIdentical(t *testing.T) {
inferred := compile(t, ctxZeros+
"fn main() -> int {\n let xs: float[] = zeros(3)\n print(to_string(length(xs)))\n return 0\n}")
explicit := compile(t, ctxZeros+
"fn main() -> int {\n let xs: float[] = zeros[float](3)\n print(to_string(length(xs)))\n return 0\n}")
if !bytes.Equal(inferred, explicit) {
t.Fatalf("context-pinned numeric mono differs from explicit form\n--- inferred ---\n%s\n--- explicit ---\n%s", inferred, explicit)
}
floatOut := compile(t, ctxZeros+
"fn main() -> int {\n let xs: float[] = zeros(3)\n return 0\n}")
intOut := compile(t, ctxZeros+
"fn main() -> int {\n let xs: int[] = zeros(3)\n return 0\n}")
if bytes.Equal(floatOut, intOut) {
t.Fatalf("numeric mono produced identical output for int and float bindings; specialization did not occur")
}
}

// SC-006 violation path: a context-pinned type that violates the numeric bound is
// rejected with the existing bound-violation error.
func TestContextInferNumericBoundViolation(t *testing.T) {
if !hasCheckErr(t, ctxZeros+
"fn main() -> int {\n let xs: string[] = zeros(3)\n return 0\n}", "does not satisfy numeric") {
t.Fatalf("expected a numeric bound-violation error")
}
}

// SC-014: a comparable-bounded return-only param pinned by context compiles
// byte-identically to the explicit form (comparable is type-erased).
const ctxEmptyOf = "fn empty_of[T: comparable]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"

func TestContextInferComparableByteIdentical(t *testing.T) {
inferred := compile(t, ctxEmptyOf+
"fn main() -> int {\n let xs: int[] = empty_of()\n print(to_string(length(xs)))\n return 0\n}")
explicit := compile(t, ctxEmptyOf+
"fn main() -> int {\n let xs: int[] = empty_of[int]()\n print(to_string(length(xs)))\n return 0\n}")
if !bytes.Equal(inferred, explicit) {
t.Fatalf("context-pinned comparable differs from explicit form\n--- inferred ---\n%s\n--- explicit ---\n%s", inferred, explicit)
}
}

// SC-014 violation path: a context-pinned non-comparable type is rejected with the
// existing comparable bound-violation error.
func TestContextInferComparableBoundViolation(t *testing.T) {
if !hasCheckErr(t, "struct S { x: int }\n"+ctxEmptyOf+
"fn main() -> int {\n let xs: S[] = empty_of()\n return 0\n}", "does not satisfy comparable") {
t.Fatalf("expected a comparable bound-violation error")
}
}
58 changes: 48 additions & 10 deletions internal/types/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func arityWant(min, max int) string {
// callee expression of function type -> indirect call; of any non-function type
// -> compile error. It validates arguments, records a CallInfo, and returns the
// result type.
func (c *checker) checkCall(n *ast.CallExpr) Type {
func (c *checker) checkCall(n *ast.CallExpr, want Type) Type {
// Explicit call-site type arguments (M9) are consumed ONLY by a direct or
// qualified user-function call. Reject them on every other callee form at this
// single choke point, BEFORE dispatch. The guard does not return: the call then
Expand All @@ -38,7 +38,7 @@ func (c *checker) checkCall(n *ast.CallExpr) Type {
c.errf(n.TypeArgs[0].Pos, "%s does not take type arguments", c.uncallableTypeArgForm(n))
}
if n.CalleeName != "" {
return c.checkNamedCall(n)
return c.checkNamedCall(n, want)
}
// A tagged-union construction call `Enum.Variant(arg)`: the callee is a
// FieldAccess whose base names an enum type (checked before the namespace path,
Expand All @@ -50,7 +50,7 @@ func (c *checker) checkCall(n *ast.CallExpr) Type {
// A qualified cross-module call `ns.fn(...)` (M8): the callee is a FieldAccess
// whose base is an in-scope namespace alias not shadowed by a local variable.
if field, modid, ok := c.qualifiedNsTarget(n.Callee); ok {
return c.checkQualifiedCall(n, field, modid)
return c.checkQualifiedCall(n, field, modid, want)
}
return c.checkIndirectCall(n, c.checkExpr(n.Callee))
}
Expand Down Expand Up @@ -97,7 +97,7 @@ func (c *checker) uncallableTypeArgForm(n *ast.CallExpr) string {
// a direct user call and records a CallUser CallInfo whose Func is the target decl
// (so reachability tree-shaking follows the cross-module edge) and whose Mangled
// uses the target module's modid.
func (c *checker) checkQualifiedCall(n *ast.CallExpr, field string, modid int) Type {
func (c *checker) checkQualifiedCall(n *ast.CallExpr, field string, modid int, want Type) Type {
// Resolve the namespace alias name for diagnostics.
nsName := n.Callee.(*ast.FieldAccess).X.(*ast.Ident).Name
// A reserved core module (json, ...) resolves its members through the core
Expand All @@ -117,11 +117,11 @@ func (c *checker) checkQualifiedCall(n *ast.CallExpr, field string, modid int) T
c.typeArgs(n.Args)
return Invalid
}
return c.checkUserCallIn(n, fn, modid)
return c.checkUserCallIn(n, fn, modid, want)
}

// checkNamedCall resolves a call whose callee is a bare identifier.
func (c *checker) checkNamedCall(n *ast.CallExpr) Type {
func (c *checker) checkNamedCall(n *ast.CallExpr, want Type) Type {
name := n.CalleeName
if name == "Some" {
return c.checkSomeCall(n)
Expand Down Expand Up @@ -151,7 +151,7 @@ func (c *checker) checkNamedCall(n *ast.CallExpr) Type {
return Invalid
}
if fn, ok := c.cur.funcs[name]; ok {
return c.checkUserCallIn(n, fn, c.cur.id)
return c.checkUserCallIn(n, fn, c.cur.id, want)
}
if isBuiltin(name) {
// ERGO-6: map/filter are removable (array namespace) but their
Expand Down Expand Up @@ -712,7 +712,7 @@ func (c *checker) checkBuiltinSig(n *ast.CallExpr, dispName, builtin string, sig
// typed as one of that module's own structs resolves to that module's token, which
// the caller's same-struct argument also resolves to -- cross-module type equality
// holds). The recorded CallInfo's Mangled carries the defining module's modid.
func (c *checker) checkUserCallIn(n *ast.CallExpr, fn *ast.FuncDecl, modid int) Type {
func (c *checker) checkUserCallIn(n *ast.CallExpr, fn *ast.FuncDecl, modid int, want Type) Type {
callName := n.CalleeName
if callName == "" {
callName = fn.Name
Expand Down Expand Up @@ -828,7 +828,7 @@ func (c *checker) checkUserCallIn(n *ast.CallExpr, fn *ast.FuncDecl, modid int)
}

if len(fn.TypeParams) > 0 {
return c.checkGenericUserCall(n, fn, modid, callName, argTypes, paramTypes, ret, explicit, seeded, typeArgPos, preSuppressed)
return c.checkGenericUserCall(n, fn, modid, callName, argTypes, paramTypes, ret, explicit, seeded, typeArgPos, preSuppressed, want)
}

for i := range fn.Params {
Expand Down Expand Up @@ -874,7 +874,7 @@ func (c *checker) checkUserCallIn(n *ast.CallExpr, fn *ast.FuncDecl, modid int)
func (c *checker) checkGenericUserCall(n *ast.CallExpr, fn *ast.FuncDecl, modid int,
callName string, argTypes, paramTypes []Type, ret Type,
explicit []Type, seeded map[string]bool, typeArgPos map[string]token.Position,
preSuppressed map[string]bool) Type {
preSuppressed map[string]bool, want Type) Type {
subst := map[string]Type{}
// Seed explicit call-site type arguments before unifying value args, so the
// same engine that infers also validates the explicit bindings (a value arg
Expand Down Expand Up @@ -949,6 +949,44 @@ func (c *checker) checkGenericUserCall(n *ast.CallExpr, fn *ast.FuncDecl, modid
}
ret = Invalid
}
// Context-directed type-argument inference (FR-001, FR-002, FR-005): when the
// call site supplies a concrete expected type, unify the callee's declared
// return type against it to bind any type parameter the value arguments left
// unbound. unify only checks consistency for an already-bound param (it never
// rebinds one), so value-argument bindings always win; a context type that
// contradicts a bound param surfaces via the same conflict path as a value-arg
// conflict. Skipped when there is no context (want == Invalid), when the call
// wrote explicit type arguments (those govern, FR-007), when an argument
// already errored, or when want itself carries an unresolved type variable
// (FR-004 concreteness: a "$"-typevar binding must never reach bound
// enforcement or monomorphization).
if want != Invalid && !anyArgErrored && len(n.TypeArgs) == 0 && !strings.Contains(string(want), "$") {
unbound := false
for _, tp := range fn.TypeParams {
if _, ok := subst[tp]; !ok {
unbound = true
break
}
}
if unbound {
tentative := cloneSubst(subst)
var cf conflict
if c.unify(ret, want, tentative, &cf) {
subst = tentative
} else if cf.found {
c.errf(n.CalleePos, "cannot infer type parameter %s of %s: bound to %s but also %s",
cf.param, callName, cf.prior, cf.next)
for _, tp := range fn.TypeParams {
suppressed[tp] = true // no cannot-infer cascade behind the conflict
}
ret = Invalid
}
// A non-conflict unify failure (a structural shape mismatch between the
// return type and the expected type) binds nothing; the still-unbound
// params fall through to the cannot-infer sweep below, unchanged from
// before this feature.
}
}
for _, tp := range fn.TypeParams {
if _, ok := subst[tp]; !ok {
if suppressed[tp] {
Expand Down
Loading
Loading