diff --git a/CHANGELOG.md b/CHANGELOG.md index f58f248..adeff8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/codegen/context_inference_codegen_test.go b/internal/codegen/context_inference_codegen_test.go new file mode 100644 index 0000000..7d8c3e3 --- /dev/null +++ b/internal/codegen/context_inference_codegen_test.go @@ -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") + } +} diff --git a/internal/types/call.go b/internal/types/call.go index 79f7bcd..5ae3260 100644 --- a/internal/types/call.go +++ b/internal/types/call.go @@ -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 @@ -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, @@ -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)) } @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 { @@ -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 @@ -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] { diff --git a/internal/types/context_inference_test.go b/internal/types/context_inference_test.go new file mode 100644 index 0000000..90e8029 --- /dev/null +++ b/internal/types/context_inference_test.go @@ -0,0 +1,179 @@ +package types + +import ( + "testing" + + "github.com/mitchellnemitz/wisp/internal/parser" +) + +// A return-only type parameter is inferred from the let-binding annotation, with +// no explicit type argument on the call. (FR-001, FR-002, FR-004a, SC-001) +func TestContextInferLetReturnOnly(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + wrapMain("let xs: int[] = empty_list()\n print(to_string(length(xs)))")) +} + +// A return-only param inside a composite return type (Optional[T]). (SC-001) +func TestContextInferLetOptional(t *testing.T) { + expectOK(t, "fn nothing[T]() -> Optional[T] {\n return None\n}\n"+ + wrapMain("let x: Optional[int] = nothing()\n print(\"ok\")")) +} + +// final binding pins the return-only param the same way. (FR-004a) +func TestContextInferFinal(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + wrapMain("final ys: string[] = empty_list()\n print(to_string(length(ys)))")) +} + +// return position: the enclosing function's declared (concrete) return type pins +// the return-only param. (FR-004b, SC-002) +func TestContextInferReturn(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + "fn make() -> bool[] {\n return empty_list()\n}\n"+ + wrapMain("let b: bool[] = make()\n print(to_string(length(b)))")) +} + +// call-argument position: a concretely-typed parameter pins the return-only param +// of a generic call passed to it. (FR-004c, SC-003) +func TestContextInferCallArg(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + "fn takes_ints(xs: int[]) -> int {\n return length(xs)\n}\n"+ + wrapMain("let n: int = takes_ints(empty_list())\n print(to_string(n))")) +} + +// assignment position: a plain-variable assignment target pins the param from the +// variable's declared type. (FR-004d, SC-004) +func TestContextInferAssign(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + wrapMain("let xs: int[] = [1]\n xs = empty_list()\n print(to_string(length(xs)))")) +} + +// array-literal element position: a known element type pins the param. (FR-004e, SC-004) +func TestContextInferArrayElem(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + wrapMain("let grid: int[][] = [empty_list()]\n print(to_string(length(grid)))")) +} + +// dict-literal VALUE element position: a known value type pins the param. (FR-004e, SC-004) +func TestContextInferDictValue(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + wrapMain("let m: {string: int[]} = {\"a\": empty_list()}\n print(to_string(length(m[\"a\"])))")) +} + +// Field-assignment RHS infers for free (uniform mechanism; see the Scope note in +// File Structure). Locking test so the behavior is not untested. +func TestContextInferFieldAssign(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + "struct Holder { items: int[] }\n"+ + wrapMain("let h: Holder = Holder { items: [1] }\n h.items = empty_list()\n print(to_string(length(h.items)))")) +} + +// Index-assignment RHS (array element target) infers for free. Locking test. +func TestContextInferIndexAssign(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + wrapMain("let grid: int[][] = [[1]]\n grid[0] = empty_list()\n print(to_string(length(grid)))")) +} + +// Value arguments win: identity's T is pinned by the argument, context agrees. (US5) +func TestContextInferArgWins(t *testing.T) { + expectOK(t, "fn identity[T](x: T) -> T {\n return x\n}\n"+ + wrapMain("let x: int = identity(5)\n print(to_string(x))")) +} + +// Value argument vs contradicting annotation: the existing initializer-mismatch +// error fires (context never overrides the arg-inferred binding). (FR-003, SC-009) +func TestContextInferArgVsAnnotationMismatch(t *testing.T) { + expectErr(t, "fn identity[T](x: T) -> T {\n return x\n}\n"+ + wrapMain("let x: string = identity(5)\n print(x)"), + "has type int, want string") +} + +// Multi-param conflict: T is arg-pinned to int, context demands string -> the +// existing unify-conflict path fires (no new error kind). (FR-005, SC-007) +func TestContextInferMultiParamConflict(t *testing.T) { + expectErr(t, "struct Pair[A, B] { first: A, second: B }\n"+ + "fn pair_with[T, U](x: T) -> Pair[T, U] {\n let u: U[] = []\n return Pair { first: x, second: u[0] }\n}\n"+ + wrapMain("let p: Pair[string, string] = pair_with(5)\n print(\"x\")"), + "cannot infer type parameter T of pair_with: bound to int but also string") +} + +// Multi-param success: T from arg, U from context. (FR-002, SC-007) +func TestContextInferMultiParamOK(t *testing.T) { + expectOK(t, "struct Pair[A, B] { first: A, second: B }\n"+ + "fn pair_with[T, U](x: T) -> Pair[T, U] {\n let u: U[] = []\n return Pair { first: x, second: u[0] }\n}\n"+ + wrapMain("let p: Pair[int, string] = pair_with(5)\n print(\"x\")")) +} + +// Nested unbound-parameter boundary: identity's param is an unbound type variable, +// so the inner empty_list() gets no concrete context -> still an error. (FR-010, SC-008) +func TestContextInferNestedUnboundErrors(t *testing.T) { + expectErr(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + "fn identity[T](x: T) -> T {\n return x\n}\n"+ + wrapMain("let xs: int[] = identity(empty_list())\n print(to_string(length(xs)))"), + "cannot infer type parameter T of empty_list") +} + +// The same nested call compiles once the inner call is given explicit type args. (SC-008) +func TestContextInferNestedExplicitOK(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + "fn identity[T](x: T) -> T {\n return x\n}\n"+ + wrapMain("let xs: int[] = identity(empty_list[int]())\n print(to_string(length(xs)))")) +} + +// Explicit type args take precedence and are NOT overridden by context: when they +// disagree with the annotation, the existing binding-mismatch error fires (the +// call's type is int[] from the explicit [int], not string[] from context). (FR-007) +func TestContextInferExplicitArgsWinOverContext(t *testing.T) { + expectErr(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + wrapMain("let xs: string[] = empty_list[int]()\n print(to_string(length(xs)))"), + "has type [int], want [string]") +} + +// Expected type carrying a type variable is not concrete: inside a generic body a +// return whose type is U[] does not drive inference; explicit args still required. (FR-004 concreteness) +func TestContextInferWantWithTypeVarErrors(t *testing.T) { + expectErr(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + "fn wraplist[U]() -> U[] {\n return empty_list()\n}\n"+ + wrapMain("return 0"), + "cannot infer type parameter T of empty_list") +} + +// The same generic body compiles when the inner call names the enclosing type param. (FR-004 concreteness) +func TestContextInferWantWithTypeVarExplicitOK(t *testing.T) { + expectOK(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + "fn wraplist[U]() -> U[] {\n return empty_list[U]()\n}\n"+ + wrapMain("let xs: int[] = wraplist[int]()\n print(to_string(length(xs)))")) +} + +// No context at all: a bare return-only call still cannot be inferred. (FR-006) +func TestContextInferNoContextErrors(t *testing.T) { + expectErr(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + "fn identity[T](x: T) -> T {\n return x\n}\n"+ + wrapMain("let xs: int[] = identity(empty_list())\n print(to_string(length(xs)))"), + "cannot infer type parameter") + + // A discarded bare call with no context also cannot infer. + expectErr(t, "fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + wrapMain("empty_list()\n return 0"), + "cannot infer type parameter T of empty_list") +} + +// No let-type inference: an unannotated let is still rejected, unchanged. (SC-010) +// The annotation is required by the grammar, so the missing `: T` surfaces as a +// parse error (before Check runs); assert the feature did not relax that. +func TestContextInferAnnotationStillRequired(t *testing.T) { + _, err := parser.Parse("fn empty_list[T]() -> T[] {\n let xs: T[] = []\n return xs\n}\n"+ + wrapMain("let xs = empty_list()\n return 0"), "test.wisp") + if err == nil { + t.Fatalf("expected a parse error for a let without a type annotation, got none") + } + + // SC-010 also covers parameter and return-type annotations: both stay + // grammar-mandatory (parse errors), unaffected by this feature. + if _, err := parser.Parse("fn f(x) -> int {\n return 0\n}\n", "test.wisp"); err == nil { + t.Fatalf("expected a parse error for a parameter without a type annotation, got none") + } + if _, err := parser.Parse("fn g() {\n return\n}\n", "test.wisp"); err == nil { + t.Fatalf("expected a parse error for a function without a return-type annotation, got none") + } +} diff --git a/internal/types/expr.go b/internal/types/expr.go index f405684..1b0acd1 100644 --- a/internal/types/expr.go +++ b/internal/types/expr.go @@ -59,7 +59,7 @@ func (c *checker) exprType(e ast.Expr, want Type) Type { case *ast.BinaryExpr: return c.checkBinary(n) case *ast.CallExpr: - return c.checkCall(n) + return c.checkCall(n, want) case *ast.StructLit: return c.checkStructLit(n, want) case *ast.ArrayLit: diff --git a/internal/types/generics_test.go b/internal/types/generics_test.go index 7b8ea3b..0f45fe7 100644 --- a/internal/types/generics_test.go +++ b/internal/types/generics_test.go @@ -128,8 +128,11 @@ func TestGenericUnifyRollback(t *testing.T) { // cannot infer: return-only parameter (single diagnostic). func TestGenericCannotInferReturnOnly(t *testing.T) { + // No context type is available here (the call's result is discarded), so the + // return-only param T is still uninferable. A `let xs: int[] = make()` form + // now succeeds via context-directed inference; see TestContextInferLetReturnOnly. info := check(t, "fn make[T]() -> T[] { return [] }\n"+ - wrapMain("let xs: int[] = make()")) + wrapMain("make()")) if !containsErr(info, "cannot infer") { t.Fatalf("want 'cannot infer', got:\n%s", diagList(info.Errors)) } diff --git a/testdata/golden/context_inference.json b/testdata/golden/context_inference.json new file mode 100644 index 0000000..861698a --- /dev/null +++ b/testdata/golden/context_inference.json @@ -0,0 +1 @@ +{"desc":"context-directed type-argument inference: return-only params inferred from let, return, call-argument, and dict-value positions with no explicit type args; identical across all four shells","stdout":"0\n0\n0\n0\n","exit":0} diff --git a/testdata/golden/context_inference.wisp b/testdata/golden/context_inference.wisp new file mode 100644 index 0000000..1163533 --- /dev/null +++ b/testdata/golden/context_inference.wisp @@ -0,0 +1,28 @@ +fn empty_list[T]() -> T[] { + let xs: T[] = [] + return xs +} + +fn make() -> int[] { + return empty_list() +} + +fn takes_strings(xs: string[]) -> int { + return length(xs) +} + +fn main() -> int { + let a: int[] = empty_list() + print(to_string(length(a))) + + let b: int[] = make() + print(to_string(length(b))) + + let n: int = takes_strings(empty_list()) + print(to_string(n)) + + let m: {string: int[]} = {"k": empty_list()} + print(to_string(length(m["k"]))) + + return 0 +} diff --git a/www/src/content/docs/guide/language.md b/www/src/content/docs/guide/language.md index f8c249d..29f43d5 100644 --- a/www/src/content/docs/guide/language.md +++ b/www/src/content/docs/guide/language.md @@ -734,8 +734,15 @@ ordered and equality comparisons, string interpolation, struct field access, `switch`, and `array.sort`/`array.sum`. Inference is local and one-pass. A type parameter that no argument constrains -(for example one that appears only in the return type) cannot be inferred; supply -it explicitly (see below). +(for example one that appears only in the return type) is inferred from the +call's context when the call sits in a position with a known type -- a `let` or +`final` annotation, a `return` in a function with a declared return type, an +argument to a concretely-typed parameter, an assignment whose target has a known +type, or an array element or dict value with a known type. So `let xs: int[] = +empty_list()` needs no `[int]`. When there is no such context -- for example the +call is nested inside another generic whose parameter is itself unconstrained, or +the enclosing type is itself generic -- the parameter still cannot be inferred; +supply it explicitly (see below). ### Explicit type arguments