Skip to content

Commit 41d317b

Browse files
committed
feat(check): typed local declarations end-to-end
Pair with the tree-sitter-rad grammar change introducing the typed_assign rule and the BLOCK_COLON external token. This commit handles the rad-side wiring: converter -> AST -> binder -> type checker -> tests, plus snapshot updates from the CST shape change. The converter desugars typed_assign into the existing Assign AST node with a new optional DeclaredType field. Keeping a single Assign kind downstream means every existing pass (walkAST, binder visit dispatch, interpreter exec) stays unchanged; the only new concept is 'this Assign carries an annotation.' One AST shape, two source forms. Symbol gains a Declared slot. The binder reads it from Assign.DeclaredType the moment a typed local is declared, and the type checker enforces the annotation on both the initial RHS AND every subsequent reassignment - the annotation is sticky for the binding's lifetime. Untyped locals stay completely unchanged: no Declared, no reassignment constraint, no diagnostic. The grammar's external-scanner trick (BLOCK_COLON only emitted when ':' is followed by a newline) means variables named exactly `rad` / `request` / `display` ALSO carry annotations unambiguously - `rad: int = 5` parses as a typed_assign, not a rad_block. A dedicated test pins that down so any future grammar regression on the scanner peek surfaces immediately. Severity stays at Hint, consistent with the rest of Phase 2's assignability checks (per-arg type-check, op overload). The runtime still produces its richer value-aware message when it runs; the static check just surfaces the issue earlier in LSP and rad check. One severity-migration pass will flip every assignability check together once literal-type fidelity exists. The ST snapshot updates are mechanical: rad_block's CST no longer contains a visible `:` literal child (BLOCK_COLON is a hidden external token) so the dump lines for that node disappear. AST shapes are unchanged.
1 parent 3e2537d commit 41d317b

18 files changed

Lines changed: 209 additions & 19 deletions

rts/check/binder.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,17 @@ func (b *binder) visitAssign(a *rl.Assign) {
272272
for _, target := range a.Targets {
273273
b.declareTarget(target, a.UpdateEnclosing)
274274
}
275+
// Typed local: `x: int = 5`. The converter only attaches
276+
// DeclaredType on single-target assigns today, so we plant the
277+
// annotation on the first target's symbol if it's a fresh local
278+
// the binder just declared.
279+
if a.DeclaredType != nil && len(a.Targets) > 0 {
280+
if ident, ok := a.Targets[0].(*rl.Identifier); ok {
281+
if sym, ok := b.resolved.Uses[ident]; ok && sym != nil {
282+
sym.Declared = *a.DeclaredType
283+
}
284+
}
285+
}
275286
if a.Catch != nil {
276287
b.visitCatch(a.Catch)
277288
}

rts/check/resolve.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ type Symbol struct {
6868
DeclSpan rl.Span // location of the declaration in source; zero for builtins
6969
DefNode rl.Node // the AST node that declared the symbol; nil for builtins
7070
Scope *Scope // scope this symbol lives in; the builtin scope for SymBuiltin
71+
// Declared is the user-written type annotation pinned to this
72+
// binding (e.g. the `int` in `x: int = 5`). Once set it never
73+
// changes; subsequent reassignments must remain assignable to it.
74+
// nil for unannotated locals - those carry only an Inferred type
75+
// that the type checker derives from the RHS.
76+
Declared rl.TypingT
7177
}
7278

7379
// Scope is a lexical name -> Symbol table chained to its parent. Lookup

rts/check/type_check.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ func (tc *typeChecker) walkCmd(c *rl.CmdBlock) {
121121
// type of the LHS symbol. Multi-value RHS aligns 1:1 with multi-
122122
// target LHS at this stage; unpacking (where one RHS expression
123123
// produces multiple values) is deferred.
124+
//
125+
// For typed locals (`x: int = 5`, with sym.Declared set) the RHS
126+
// must be assignable to the declared type, and the recorded symbol
127+
// type stays Declared rather than the RHS-derived value. Subsequent
128+
// `x = something` reassignments are checked against the same
129+
// Declared, so the annotation acts as a stable contract for every
130+
// later read of the binding.
124131
func (tc *typeChecker) walkAssign(a *rl.Assign) {
125132
for i, val := range a.Values {
126133
valType := tc.synth(val)
@@ -135,10 +142,41 @@ func (tc *typeChecker) walkAssign(a *rl.Assign) {
135142
if !ok {
136143
continue
137144
}
145+
if sym.Declared != nil {
146+
tc.checkAssignAgainstDeclared(val, valType, sym.Declared)
147+
tc.info.SymbolTypes[sym] = sym.Declared
148+
continue
149+
}
138150
tc.info.SymbolTypes[sym] = valType
139151
}
140152
}
141153

154+
// checkAssignAgainstDeclared emits a type-mismatch when the assigned
155+
// value can't flow into the declared slot. Severity matches Phase 2's
156+
// per-arg precedent: Hint, not Error - the runtime still produces a
157+
// richer value-aware message when the script runs, and we only
158+
// promote once literal types fill the missing fidelity (a one-pass
159+
// severity migration covers all assignability checks at once).
160+
//
161+
// ErrorType / Dynamic short-circuit: a poisoned RHS already produced
162+
// a diagnostic and any-likes are universally assignable, so no extra
163+
// diagnostic fires.
164+
func (tc *typeChecker) checkAssignAgainstDeclared(valNode rl.Node, valType, declared rl.TypingT) {
165+
if valType == nil || isErrorType(valType) || isDynamicLike(valType) {
166+
return
167+
}
168+
if declared.IsAssignableFrom(valType) {
169+
return
170+
}
171+
tc.info.Issues = append(tc.info.Issues, BindIssue{
172+
Span: valNode.Span(),
173+
Severity: IssueHint,
174+
Code: rl.ErrTypeMismatch,
175+
Message: fmt.Sprintf("Value of type '%s' is not assignable to declared type '%s'",
176+
valType.Name(), declared.Name()),
177+
})
178+
}
179+
142180
// synth returns the static type of an expression node and records it
143181
// on the ExprTypes index.
144182
//

rts/check/type_check_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,3 +668,112 @@ func TestTypeCheck_IntPlusListDoesNotAttachStrMigrationHint(t *testing.T) {
668668
}
669669
}
670670
}
671+
672+
// --- Typed local declaration tests (Phase 3) -------------------------
673+
674+
func TestTypeCheck_TypedLocalRecordsDeclaredType(t *testing.T) {
675+
// `x: int = 5` - the symbol's recorded type should be the
676+
// declared `int`, not the RHS's synth result (which happens to
677+
// also be int here, but the point is the binder set Declared and
678+
// the checker used it).
679+
file, info, resolved := typeInfoFromSrc(t, "x: int = 5\n")
680+
target := file.Stmts[0].(*rl.Assign).Targets[0].(*rl.Identifier)
681+
sym := resolved.Uses[target]
682+
require.NotNil(t, sym)
683+
require.NotNil(t, sym.Declared, "binder should populate Declared")
684+
assert.Equal(t, rl.T_INT, sym.Declared.Name())
685+
assert.Equal(t, rl.T_INT, info.SymbolTypes[sym].Name())
686+
}
687+
688+
func TestTypeCheck_TypedLocalAcceptsAssignableRHS(t *testing.T) {
689+
// int literal flows into `: int` slot without diagnostic.
690+
_, info, _ := typeInfoFromSrc(t, "x: int = 5\n")
691+
for _, i := range info.Issues {
692+
assert.NotEqual(t, rl.ErrTypeMismatch, i.Code,
693+
"valid typed local should not produce a type-mismatch")
694+
}
695+
}
696+
697+
func TestTypeCheck_TypedLocalRejectsIncompatibleRHS(t *testing.T) {
698+
// str literal can't flow into `: int`. Hint severity matches the
699+
// rest of Phase 2's assignability precedent.
700+
_, info, _ := typeInfoFromSrc(t, "x: int = \"hi\"\n")
701+
found := false
702+
for _, i := range info.Issues {
703+
if i.Code == rl.ErrTypeMismatch && i.Severity == check.IssueHint {
704+
found = true
705+
break
706+
}
707+
}
708+
assert.True(t, found,
709+
"expected a Hint-severity type-mismatch when RHS isn't assignable to declared")
710+
}
711+
712+
func TestTypeCheck_TypedLocalDeclaredFloatAcceptsInt(t *testing.T) {
713+
// The single implicit widening: int flows into float. So
714+
// `x: float = 5` is fine, no diagnostic.
715+
_, info, _ := typeInfoFromSrc(t, "x: float = 5\n")
716+
for _, i := range info.Issues {
717+
assert.NotEqual(t, rl.ErrTypeMismatch, i.Code)
718+
}
719+
}
720+
721+
func TestTypeCheck_TypedLocalReassignChecksAgainstDeclared(t *testing.T) {
722+
// After `x: int = 5`, a later `x = "hi"` should still be flagged
723+
// against the original declared type. The annotation is sticky
724+
// for the binding's lifetime.
725+
_, info, _ := typeInfoFromSrc(t, "x: int = 5\nx = \"hi\"\n")
726+
count := 0
727+
for _, i := range info.Issues {
728+
if i.Code == rl.ErrTypeMismatch {
729+
count++
730+
}
731+
}
732+
assert.Equal(t, 1, count,
733+
"reassignment with incompatible type should fire one type-mismatch")
734+
}
735+
736+
func TestTypeCheck_TypedLocalDeclaredAnyAcceptsAnything(t *testing.T) {
737+
// `: any` is the user-opt-in escape hatch - every type flows in.
738+
_, info, _ := typeInfoFromSrc(t, "x: any = 5\nx = \"hi\"\n")
739+
for _, i := range info.Issues {
740+
assert.NotEqual(t, rl.ErrTypeMismatch, i.Code,
741+
"any-typed local should accept any RHS")
742+
}
743+
}
744+
745+
func TestTypeCheck_UntypedLocalUnchanged(t *testing.T) {
746+
// Sanity check that the introduction of typed locals doesn't
747+
// disturb the existing untyped path. `x = 5; x = "hi"` should
748+
// still rebind freely (no declared annotation means no
749+
// reassignment constraint).
750+
_, info, _ := typeInfoFromSrc(t, "x = 5\nx = \"hi\"\n")
751+
for _, i := range info.Issues {
752+
assert.NotEqual(t, rl.ErrTypeMismatch, i.Code,
753+
"untyped local should accept any reassignment")
754+
}
755+
}
756+
757+
func TestTypeCheck_TypedLocalWithRadBlockKeywordName(t *testing.T) {
758+
// Variables named `rad` / `request` / `display` (the rad_block
759+
// keywords) can ALSO be typed-locals. The external scanner's
760+
// BLOCK_COLON peek distinguishes a typed-assign's ':' from a
761+
// rad_block's block-colon by what follows it, so `rad: int = 5`
762+
// is unambiguously a typed-local declaration regardless of the
763+
// keyword overlap. This test pins that down.
764+
file, info, resolved := typeInfoFromSrc(t, "rad: int = 5\n")
765+
require.NotEmpty(t, file.Stmts, "should produce one stmt")
766+
assign, ok := file.Stmts[0].(*rl.Assign)
767+
require.True(t, ok, "expected Assign, got %T", file.Stmts[0])
768+
require.NotNil(t, assign.DeclaredType,
769+
"typed_assign with `rad` LHS should carry DeclaredType")
770+
ident := assign.Targets[0].(*rl.Identifier)
771+
assert.Equal(t, "rad", ident.Name)
772+
sym := resolved.Uses[ident]
773+
require.NotNil(t, sym)
774+
assert.Equal(t, rl.T_INT, sym.Declared.Name())
775+
for _, i := range info.Issues {
776+
assert.NotEqual(t, rl.ErrTypeMismatch, i.Code,
777+
"int RHS into `rad: int` should not produce a mismatch")
778+
}
779+
}

rts/converter.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ func (c *converter) convertStmt(node *ts.Node) rl.Node {
8181
switch node.Kind() {
8282
case rl.K_ASSIGN:
8383
return c.convertAssign(node)
84+
case rl.K_TYPED_ASSIGN:
85+
return c.convertTypedAssign(node)
8486
case rl.K_COMPOUND_ASSIGN:
8587
return c.convertCompoundAssign(node)
8688
case rl.K_INCR_DECR:
@@ -146,6 +148,32 @@ func (c *converter) convertAssign(node *ts.Node) *rl.Assign {
146148
return rl.NewAssign(c.makeSpan(node), targets, values, true, catch)
147149
}
148150

151+
// convertTypedAssign desugars `x: int = 5` into the same Assign AST
152+
// node the rest of the pipeline already understands, with the
153+
// declared type attached. Keeping the shape uniform means every
154+
// downstream pass (walkers, binder, checker, interpreter) sees one
155+
// Assign kind whether the user wrote a type or not.
156+
func (c *converter) convertTypedAssign(node *ts.Node) *rl.Assign {
157+
catchNode := rl.GetChild(node, rl.F_CATCH)
158+
var catch *rl.CatchBlock
159+
if catchNode != nil {
160+
catch = c.convertCatchBlock(catchNode)
161+
}
162+
163+
leftNode := rl.GetChild(node, rl.F_LEFT)
164+
rightNode := rl.GetChild(node, rl.F_RIGHT)
165+
target := c.convertExpr(leftNode)
166+
value := c.convertExpr(rightNode)
167+
168+
assign := rl.NewAssign(c.makeSpan(node), []rl.Node{target}, []rl.Node{value}, false, catch)
169+
170+
if typeNode := rl.GetChild(node, rl.F_DECLARED_TYPE); typeNode != nil {
171+
declaredType := rl.ResolveTyping(typeNode, c.src)
172+
assign.DeclaredType = &declaredType
173+
}
174+
return assign
175+
}
176+
149177
// convertCompoundAssign desugars `x += 3` into `Assign(x, OpBinary(x, +, 3))`.
150178
func (c *converter) convertCompoundAssign(node *ts.Node) *rl.Assign {
151179
leftNode := rl.GetChild(node, rl.F_LEFT)

rts/rl/ast_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ type Assign struct {
2626
IsUnpacking bool // true if `a, b = ...` syntax
2727
UpdateEnclosing bool // true for compound assign/incr-decr (updates enclosing scope)
2828
Catch *CatchBlock // optional catch block
29+
// DeclaredType is the optional `: type` annotation on the LHS of
30+
// typed local declarations (`x: int = 5`). Today only the
31+
// single-target form carries one; nil for every other shape and
32+
// for plain untyped assigns. The binder reads this onto the
33+
// declared Symbol so the type checker can enforce the RHS
34+
// against it.
35+
DeclaredType *TypingT
2936
}
3037

3138
func NewAssign(span Span, targets, values []Node, isUnpacking bool, catch *CatchBlock) *Assign {

rts/rl/consts.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const (
5151
K_MAP = "map"
5252
K_IDENTIFIER = "identifier"
5353
K_COMPOUND_ASSIGN = "compound_assign"
54+
K_TYPED_ASSIGN = "typed_assign"
5455
K_PLUS_EQUAL = "+="
5556
K_MINUS_EQUAL = "-="
5657
K_STAR_EQUAL = "*="
@@ -169,6 +170,7 @@ const (
169170
F_CATCH = "catch"
170171
F_TYPE = "type"
171172
F_RETURN_TYPE = "return_type"
173+
F_DECLARED_TYPE = "declared_type"
172174
F_VARARG_MARKER = "vararg_marker"
173175
F_VARIADIC_MARKER = "variadic_marker" // todo merge with above
174176
F_OPTIONAL = "optional"

rts/rl/typing_resolution.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ func resolveParams(resolvedParams *[]TypingFnParam, src string, paramNodes []ts.
6666
}
6767
}
6868

69+
// ResolveTyping is the public entry point to resolveTyping for callers
70+
// outside the rl package (e.g. the converter wiring typed locals).
71+
// Accepts a CST node of kind fn_param_or_return_type and returns the
72+
// corresponding static TypingT.
73+
func ResolveTyping(node *ts.Node, src string) TypingT {
74+
return resolveTyping(node, src)
75+
}
76+
6977
// input node expected to be kind 'fn_param_or_return_type'
7078
func resolveTyping(node *ts.Node, src string) TypingT {
7179
leafNodes := GetChildren(node, F_LEAF_TYPE)

rts/test/st_snapshots/complete.snap

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,6 @@ B: [ 477, 480] PS: [ 37, 4] PE: [ 37, 7] delegate: fallba
744744
B: [ 477, 480] PS: [ 37, 4] PE: [ 37, 7] delegate: catch_expr
745745
B: [ 477, 480] PS: [ 37, 4] PE: [ 37, 7] delegate: var_path
746746
B: [ 477, 480] PS: [ 37, 4] PE: [ 37, 7] root: identifier `url`
747-
B: [ 480, 481] PS: [ 37, 7] PE: [ 37, 8] : `:`
748747
B: [ 486, 499] PS: [ 38, 4] PE: [ 38, 17] stmt: rad_field_stmt
749748
B: [ 486, 492] PS: [ 38, 4] PE: [ 38, 10] fields `fields`
750749
B: [ 493, 495] PS: [ 38, 11] PE: [ 38, 13] identifier: identifier `f1`
@@ -1019,7 +1018,6 @@ B: [ 755, 758] PS: [ 59, 4] PE: [ 59, 7] delegate: fallba
10191018
B: [ 755, 758] PS: [ 59, 4] PE: [ 59, 7] delegate: catch_expr
10201019
B: [ 755, 758] PS: [ 59, 4] PE: [ 59, 7] delegate: var_path
10211020
B: [ 755, 758] PS: [ 59, 4] PE: [ 59, 7] root: identifier `url`
1022-
B: [ 758, 759] PS: [ 59, 7] PE: [ 59, 8] : `:`
10231021
B: [ 764, 771] PS: [ 60, 4] PE: [ 60, 11] stmt: rad_option_stmt
10241022
B: [ 764, 771] PS: [ 60, 4] PE: [ 60, 11] keyword: rad_option_keyword
10251023
B: [ 764, 771] PS: [ 60, 4] PE: [ 60, 11] noprint `noprint`
@@ -1029,7 +1027,6 @@ B: [ 783, 785] PS: [ 61, 11] PE: [ 61, 13] identifier: identifier `f1`
10291027
B: [ 787, 813] PS: [ 63, 0] PE: [ 64, 21] rad_block
10301028
B: [ 787, 790] PS: [ 63, 0] PE: [ 63, 3] rad_type: rad_keyword
10311029
B: [ 787, 790] PS: [ 63, 0] PE: [ 63, 3] rad `rad`
1032-
B: [ 790, 791] PS: [ 63, 3] PE: [ 63, 4] : `:`
10331030
B: [ 796, 813] PS: [ 64, 4] PE: [ 64, 21] stmt: rad_field_stmt
10341031
B: [ 796, 802] PS: [ 64, 4] PE: [ 64, 10] fields `fields`
10351032
B: [ 803, 805] PS: [ 64, 11] PE: [ 64, 13] identifier: identifier `f2`

rts/test/st_snapshots/display_with_map.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ B: [11, 12] PS: [1, 4] PE: [1, 5] delegate: fallback_expr
4141
B: [11, 12] PS: [1, 4] PE: [1, 5] delegate: catch_expr
4242
B: [11, 12] PS: [1, 4] PE: [1, 5] delegate: var_path
4343
B: [11, 12] PS: [1, 4] PE: [1, 5] root: identifier `a`
44-
B: [12, 13] PS: [1, 5] PE: [1, 6] : `:`
4544
B: [18, 33] PS: [2, 4] PE: [2, 19] stmt: rad_field_stmt
4645
B: [18, 24] PS: [2, 4] PE: [2, 10] fields `fields`
4746
B: [25, 27] PS: [2, 11] PE: [2, 13] identifier: identifier `ID`

0 commit comments

Comments
 (0)