Skip to content

Commit cddc15e

Browse files
committed
feat(check): static synthesis for list and map literals
Extend synth() to handle LitList and LitMap so every collection literal carries a real element-type today, instead of falling through to Dynamic. `[1, 2, 3]` synthesizes to int[], `{"a": 1}` to {str: int}, and so on - exposed via ExprTypes for hover, and ready to feed downstream type-checking once collection-typed locals exist (Phase 6). Element widening applies the one and only implicit numeric rule the rest of the type system already enforces (int -> float). So [1, 2.5] is float[], not int|float[]: matches what IsAssignableFrom does at scalar slots, and avoids unions of numeric types leaking into hover and diagnostics. Non-numeric mixes do form unions ([1, "hi"] -> int|str[]), since there's no implicit widening between unrelated types. Empty literals stay at the unparameterized AnyList / AnyMap. The plan calls for Pyright-style look-around (`xs = []` then later `xs = [1, 2]` would refine to int[]), but that needs multi-statement reasoning the binder doesn't expose yet. Until then the over-approximation is correct: every existing program type-checks, and we don't nag users to annotate empty literals. ErrorType propagation: a poisoned element collapses the whole literal to ErrorType, not List<ErrorType>. The invariant collection assignability rule rejects List<X> against List<ErrorType> for every X, so wrapping would cascade the diagnostic into every downstream use of the literal. Returning bare ErrorType is the cascade-suppression contract the type system already follows.
1 parent 684a7e4 commit cddc15e

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

rts/check/type_check.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@ func (tc *typeChecker) synth(n rl.Node) rl.TypingT {
184184
return tc.synthFallback(v)
185185
case *rl.CatchExpr:
186186
return tc.synthCatchExpr(v)
187+
case *rl.LitList:
188+
return tc.synthLitList(v)
189+
case *rl.LitMap:
190+
return tc.synthLitMap(v)
187191
}
188192
for _, child := range n.Children() {
189193
_ = tc.synth(child)
@@ -787,3 +791,103 @@ func (tc *typeChecker) addUnaryOpIssue(span rl.Span, op rl.Operator, operand rl.
787791
operand.Name(), op.String()),
788792
})
789793
}
794+
795+
// --- Collection literals ---------------------------------------------
796+
//
797+
// List and map literals synthesize to a parameterized collection type
798+
// derived from their elements. Empty literals fall back to the
799+
// unparameterized AnyList/AnyMap rather than erroring: that's the
800+
// gradual-typing choice. A future "look-around" pass can refine
801+
// `xs = []` followed by `xs.append(1)` to `List<int>`, but the safe
802+
// over-approximation lets every existing program type-check today.
803+
804+
func (tc *typeChecker) synthLitList(n *rl.LitList) rl.TypingT {
805+
if len(n.Elements) == 0 {
806+
return tc.record(n, rl.NewAnyListType())
807+
}
808+
elemTypes := make([]rl.TypingT, 0, len(n.Elements))
809+
for _, e := range n.Elements {
810+
elemTypes = append(elemTypes, tc.synth(e))
811+
}
812+
widened := widenElementTypes(elemTypes)
813+
// Bare-ErrorType element poisons the whole literal. Wrapping it in
814+
// List<ErrorType> would cascade: invariant collection assignability
815+
// rejects List<X>.IsAssignableFrom(List<ErrorType>) for every X.
816+
if isErrorType(widened) {
817+
return tc.record(n, rl.NewErrorTypeType())
818+
}
819+
return tc.record(n, rl.NewListType(widened))
820+
}
821+
822+
func (tc *typeChecker) synthLitMap(n *rl.LitMap) rl.TypingT {
823+
if len(n.Entries) == 0 {
824+
return tc.record(n, rl.NewAnyMapType())
825+
}
826+
keyTypes := make([]rl.TypingT, 0, len(n.Entries))
827+
valTypes := make([]rl.TypingT, 0, len(n.Entries))
828+
for _, e := range n.Entries {
829+
keyTypes = append(keyTypes, tc.synth(e.Key))
830+
valTypes = append(valTypes, tc.synth(e.Value))
831+
}
832+
keyT := widenElementTypes(keyTypes)
833+
valT := widenElementTypes(valTypes)
834+
if isErrorType(keyT) || isErrorType(valT) {
835+
return tc.record(n, rl.NewErrorTypeType())
836+
}
837+
return tc.record(n, rl.NewMapType(keyT, valT))
838+
}
839+
840+
// widenElementTypes computes the static element type for a collection
841+
// from its individual element types. The rules:
842+
//
843+
// - If any element is ErrorType, return ErrorType so cascading
844+
// diagnostics stay suppressed.
845+
// - If any element is any/dynamic, return Dynamic - we can't pin a
846+
// useful element type and AnyList/AnyMap is a more honest answer.
847+
// - Apply the lone implicit numeric widening: a mix of int and float
848+
// collapses to float (matching IsAssignableFrom), not int|float.
849+
// Otherwise unique types form a union; identical types collapse.
850+
//
851+
// Returns Dynamic for an empty slice as a defensive fallback; callers
852+
// handle the truly-empty case (LitList{}, LitMap{}) before getting
853+
// here.
854+
func widenElementTypes(types []rl.TypingT) rl.TypingT {
855+
if len(types) == 0 {
856+
return rl.NewDynamicType()
857+
}
858+
allNumeric := true
859+
hasFloat := false
860+
for _, t := range types {
861+
if isErrorType(t) {
862+
return rl.NewErrorTypeType()
863+
}
864+
if isDynamicLike(t) {
865+
return rl.NewDynamicType()
866+
}
867+
if !isNumeric(t) {
868+
allNumeric = false
869+
}
870+
if isFloat(t) {
871+
hasFloat = true
872+
}
873+
}
874+
if allNumeric && hasFloat {
875+
return rl.NewFloatType()
876+
}
877+
// Deduplicate by Name(). Order-preserving so a list literal of all
878+
// `str` stays `List<str>` rather than getting reordered.
879+
seen := map[string]bool{}
880+
unique := make([]rl.TypingT, 0, len(types))
881+
for _, t := range types {
882+
name := t.Name()
883+
if seen[name] {
884+
continue
885+
}
886+
seen[name] = true
887+
unique = append(unique, t)
888+
}
889+
if len(unique) == 1 {
890+
return unique[0]
891+
}
892+
return rl.NewUnionType(unique...)
893+
}

rts/check/type_check_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,68 @@ func TestTypeCheck_DynamicOperandDoesNotFireDiagnostic(t *testing.T) {
406406
assert.False(t, hasOpIssue(info),
407407
"dynamic operand should suppress the type-mismatch hint")
408408
}
409+
410+
// --- Collection literal tests ----------------------------------------
411+
412+
func TestTypeCheck_ListLiteralAllInt(t *testing.T) {
413+
// Homogeneous int list synthesizes to int[].
414+
file, info, _ := typeInfoFromSrc(t, "x = [1, 2, 3]\n")
415+
assert.Equal(t, "int[]", exprTypeOf(t, file, info).Name())
416+
}
417+
418+
func TestTypeCheck_ListLiteralIntAndFloatWidensToFloat(t *testing.T) {
419+
// The plan's one and only implicit numeric widening: a mix of int
420+
// and float collapses to List<float> rather than List<int|float>.
421+
// Matches IsAssignableFrom (int flows into float at scalar slots).
422+
file, info, _ := typeInfoFromSrc(t, "x = [1, 2.5, 3]\n")
423+
assert.Equal(t, "float[]", exprTypeOf(t, file, info).Name())
424+
}
425+
426+
func TestTypeCheck_ListLiteralMixedNonNumericProducesUnion(t *testing.T) {
427+
// Non-numeric mixes don't widen - the element type is a union.
428+
file, info, _ := typeInfoFromSrc(t, "x = [1, \"hi\"]\n")
429+
assert.Equal(t, "int|str[]", exprTypeOf(t, file, info).Name())
430+
}
431+
432+
func TestTypeCheck_ListLiteralEmptyIsAnyList(t *testing.T) {
433+
// Empty literals fall back to the unparameterized form. No
434+
// "annotation required" nagging - a future look-around pass can
435+
// refine `xs = []` from later assignments / mutations.
436+
file, info, _ := typeInfoFromSrc(t, "x = []\n")
437+
assert.Equal(t, "list", exprTypeOf(t, file, info).Name())
438+
}
439+
440+
func TestTypeCheck_MapLiteralStrIntEntries(t *testing.T) {
441+
file, info, _ := typeInfoFromSrc(t, "x = {\"a\": 1, \"b\": 2}\n")
442+
assert.Equal(t, "{ str: int }", exprTypeOf(t, file, info).Name())
443+
}
444+
445+
func TestTypeCheck_MapLiteralMixedValueTypesProducesUnion(t *testing.T) {
446+
// Non-widening mix on the value side: keys stay str, values
447+
// become int|str.
448+
file, info, _ := typeInfoFromSrc(t, "x = {\"a\": 1, \"b\": \"two\"}\n")
449+
got := exprTypeOf(t, file, info).Name()
450+
assert.Equal(t, "{ str: int|str }", got)
451+
}
452+
453+
func TestTypeCheck_MapLiteralEmptyIsAnyMap(t *testing.T) {
454+
file, info, _ := typeInfoFromSrc(t, "x = {}\n")
455+
assert.Equal(t, "map", exprTypeOf(t, file, info).Name())
456+
}
457+
458+
func TestTypeCheck_NestedListPreservesInnerType(t *testing.T) {
459+
// `[[1, 2], [3]]` -> outer is List<List<int>>. Confirms that
460+
// element type propagates through recursive synth.
461+
file, info, _ := typeInfoFromSrc(t, "x = [[1, 2], [3]]\n")
462+
assert.Equal(t, "int[][]", exprTypeOf(t, file, info).Name())
463+
}
464+
465+
func TestTypeCheck_ListLiteralWithErrorElementPoisons(t *testing.T) {
466+
// If any element is ErrorType (typically because its sub-expr
467+
// already failed), the whole literal becomes ErrorType so we
468+
// don't cascade diagnostics across the bad element's siblings.
469+
// Construction: `-"hi"` produces ErrorType, putting it in a list
470+
// poisons the list's element type.
471+
file, info, _ := typeInfoFromSrc(t, "x = [1, -\"hi\", 3]\n")
472+
assert.Equal(t, "<error>", exprTypeOf(t, file, info).Name())
473+
}

0 commit comments

Comments
 (0)