Skip to content

Commit 95c58f2

Browse files
committed
compiler: add variable type annotations and flow-sensitive type checks
Rugo now catches more type mistakes at compile time, before you run your program. Variable type annotations You can now annotate local variables, the same way you already annotate function parameters and return types: name : string = "alice" count : int = 0 The annotation sticks for the rest of the scope. If you later try to assign a value of the wrong type, the compiler tells you: count = "oops" # error: cannot assign string value to variable 'count' declared as int Use `: any` when you genuinely want a variable to hold different types: value : any = 42 value = "now a string" # ok Smarter checks across branches and reassignments The compiler now follows the type of each variable through `if`, `case`, `while` and `for`, so it can flag bad calls and returns even when the offending value flows through a variable: def greet(name : string) puts "hi #{name}" end x = 42 greet(x) # error: cannot pass int value as argument 1 to 'greet' # (parameter 'name' declared as string) Return statements get the same treatment: def lookup(id : int) : string if id < 0 return id # error: cannot return int value from # function declared returning string end "ok" end When a variable could hold one of several types depending on which branch ran, Rugo unifies them and only complains if every possible type would be wrong. This keeps real code quiet while still catching genuine mistakes. Notes - Existing programs continue to compile and run unchanged; annotations are opt-in. - Error messages name the variable or parameter, the declared type, and the actual type, and point at the offending line. - Documentation updated in docs/language.md.
1 parent ee5d5f4 commit 95c58f2

18 files changed

Lines changed: 2390 additions & 503 deletions

ast/nodes.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,16 @@ type ExprStmt struct {
283283
func (e *ExprStmt) node() {}
284284
func (e *ExprStmt) stmt() {}
285285

286-
// AssignStmt represents target = value.
286+
// AssignStmt represents target = value, optionally with a binding type
287+
// annotation (`x : int = 42`). TypeAnnot is "" when there is no
288+
// annotation. Annotations are sticky and validated as binding-only:
289+
// the parser/walker rejects them on index and field mutations.
287290
type AssignStmt struct {
288291
BaseStmt
289292
Target string
290293
Value Expr
291294
Namespace string // non-empty for top-level assignments from require'd files
295+
TypeAnnot string // "" if no annotation
292296
}
293297

294298
func (a *AssignStmt) node() {}

ast/var_annot_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package ast
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// findFirstAssign returns the first AssignStmt at the top level of the
11+
// program. Used by the variable-annotation tests below to inspect the
12+
// walker's output.
13+
func findFirstAssign(t *testing.T, prog *Program) *AssignStmt {
14+
t.Helper()
15+
for _, st := range prog.Statements {
16+
if a, ok := st.(*AssignStmt); ok {
17+
return a
18+
}
19+
}
20+
t.Fatalf("no AssignStmt found in program")
21+
return nil
22+
}
23+
24+
func parseVarAnnotSource(t *testing.T, src string) (*Program, error) {
25+
t.Helper()
26+
c := &Compiler{}
27+
return c.ParseSource(src, "test.rugo")
28+
}
29+
30+
// TestVarAnnotParsesTypeAnnot exercises the `x : T = expr` syntax for
31+
// each of the 8 recognised type names. Each test asserts the resulting
32+
// AssignStmt carries the expected TypeAnnot string.
33+
func TestVarAnnotParsesTypeAnnot(t *testing.T) {
34+
cases := []struct {
35+
name string
36+
src string
37+
want string
38+
}{
39+
{"int", "x : int = 42", "int"},
40+
{"float", "x : float = 3.14", "float"},
41+
{"string", `x : string = "hi"`, "string"},
42+
{"bool", "x : bool = true", "bool"},
43+
{"array", "x : array = [1, 2, 3]", "array"},
44+
{"hash", "x : hash = {1 => 2}", "hash"},
45+
{"nil", "x : nil = nil", "nil"},
46+
{"any", "x : any = 42", "any"},
47+
}
48+
for _, tc := range cases {
49+
t.Run(tc.name, func(t *testing.T) {
50+
prog, err := parseVarAnnotSource(t, tc.src)
51+
require.NoError(t, err)
52+
a := findFirstAssign(t, prog)
53+
assert.Equal(t, tc.want, a.TypeAnnot,
54+
"AssignStmt.TypeAnnot for %q", tc.src)
55+
})
56+
}
57+
}
58+
59+
// TestVarAnnotEmptyWithoutAnnotation asserts that `x = 42` without an
60+
// annotation produces AssignStmt.TypeAnnot == "".
61+
func TestVarAnnotEmptyWithoutAnnotation(t *testing.T) {
62+
prog, err := parseVarAnnotSource(t, "x = 42")
63+
require.NoError(t, err)
64+
a := findFirstAssign(t, prog)
65+
assert.Equal(t, "", a.TypeAnnot)
66+
}
67+
68+
// TestVarAnnotRejectsIndexAndDotAssign asserts that index and field
69+
// assignments cannot carry a type annotation (those are mutations, not
70+
// bindings).
71+
func TestVarAnnotRejectsIndexAndDotAssign(t *testing.T) {
72+
cases := []struct {
73+
name string
74+
src string
75+
}{
76+
{"index assign", "arr = [1, 2]\narr[0] : int = 99"},
77+
{"dot assign", "obj.field : int = 5"},
78+
}
79+
for _, tc := range cases {
80+
t.Run(tc.name, func(t *testing.T) {
81+
_, err := parseVarAnnotSource(t, tc.src)
82+
assert.Error(t, err, "expected parse/walk error for %q", tc.src)
83+
})
84+
}
85+
}

ast/walker.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,21 +1107,36 @@ func (w *walker) walkReturnStmt(ast []int32) (Statement, error) {
11071107
}
11081108

11091109
func (w *walker) walkAssignOrExpr(ast []int32) (Statement, error) {
1110-
// AssignOrExpr = Expr [ '=' Expr ] .
1110+
// AssignOrExpr = Expr [ ':' TypeName '=' Expr | '=' Expr ] .
11111111
lhs, ast, err := w.walkExpr(ast)
11121112
if err != nil {
11131113
return nil, err
11141114
}
11151115
if len(ast) > 0 {
1116+
// Look at the next token: either ':' (annotated binding) or '='.
1117+
var typeAnnot string
1118+
if ast[0] >= 0 {
1119+
tok := w.p.Token(ast[0])
1120+
if parser.Symbol(tok.Ch) == parser.RugoTOK_003a { // ':'
1121+
_, ast = w.readToken(ast) // consume ':'
1122+
name, rest := w.walkTypeName(ast)
1123+
ast = rest
1124+
typeAnnot = name
1125+
}
1126+
}
11161127
// '=' followed by Expr
11171128
_, ast = w.readToken(ast) // '='
11181129
rhs, _, err := w.walkExpr(ast)
11191130
if err != nil {
11201131
return nil, err
11211132
}
1122-
// LHS must be an identifier or index expression for assignment
1133+
// LHS must be an identifier or index expression for assignment.
1134+
// Type annotations are valid only on plain identifier bindings.
11231135
if ident, ok := lhs.(*IdentExpr); ok {
1124-
return &AssignStmt{Target: ident.Name, Value: rhs}, nil
1136+
return &AssignStmt{Target: ident.Name, Value: rhs, TypeAnnot: typeAnnot}, nil
1137+
}
1138+
if typeAnnot != "" {
1139+
return nil, &UserError{Msg: "type annotation is only valid on a plain variable binding (`x : T = expr`); index and field assignments are mutations and cannot carry an annotation"}
11251140
}
11261141
if idx, ok := lhs.(*IndexExpr); ok {
11271142
return &IndexAssignStmt{Object: idx.Object, Index: idx.Index, Value: rhs}, nil

compiler/check_annot.go

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,64 +25,86 @@ func TypeAnnotationCheck(sourceFile string) ast.Check {
2525
func (a *annotCheck) Name() string { return "type-annotation" }
2626

2727
func (a *annotCheck) Check(prog *ast.Program) error {
28+
annotated := map[string]bool{}
2829
for _, s := range prog.Statements {
29-
if err := a.checkStmt(s); err != nil {
30+
if err := a.checkStmt(s, annotated); err != nil {
3031
return err
3132
}
3233
}
3334
return nil
3435
}
3536

36-
func (a *annotCheck) checkStmt(s ast.Statement) error {
37+
func (a *annotCheck) checkStmt(s ast.Statement, annotated map[string]bool) error {
3738
switch st := s.(type) {
3839
case *ast.FuncDef:
3940
if err := validateFuncAnnotations(st, a.sourceFile); err != nil {
4041
return err
4142
}
43+
// Function bodies get their own annotation scope -- a `x : int = ...`
44+
// inside the body is independent of any outer binding.
45+
localAnnots := map[string]bool{}
4246
for _, child := range st.Body {
43-
if err := a.checkStmt(child); err != nil {
47+
if err := a.checkStmt(child, localAnnots); err != nil {
4448
return err
4549
}
4650
}
51+
case *ast.AssignStmt:
52+
if st.TypeAnnot != "" {
53+
if _, ok := ParseTypeAnnotation(st.TypeAnnot); !ok {
54+
return &ast.UserError{Msg: fmt.Sprintf(
55+
"%s:%d: unknown type %q in annotation for variable '%s' (valid types: %s)",
56+
a.sourceFile, st.SourceLine, st.TypeAnnot, st.Target, strings.Join(KnownTypeNames(), ", "),
57+
)}
58+
}
59+
if annotated[st.Target] {
60+
return &ast.UserError{Msg: fmt.Sprintf(
61+
"%s:%d: re-annotation of variable '%s' (annotations are sticky bindings — assign without `: T` to update an annotated variable)",
62+
a.sourceFile, st.SourceLine, st.Target,
63+
)}
64+
}
65+
annotated[st.Target] = true
66+
}
4767
case *ast.TestDef:
68+
localAnnots := map[string]bool{}
4869
for _, child := range st.Body {
49-
if err := a.checkStmt(child); err != nil {
70+
if err := a.checkStmt(child, localAnnots); err != nil {
5071
return err
5172
}
5273
}
5374
case *ast.BenchDef:
75+
localAnnots := map[string]bool{}
5476
for _, child := range st.Body {
55-
if err := a.checkStmt(child); err != nil {
77+
if err := a.checkStmt(child, localAnnots); err != nil {
5678
return err
5779
}
5880
}
5981
case *ast.IfStmt:
6082
for _, child := range st.Body {
61-
if err := a.checkStmt(child); err != nil {
83+
if err := a.checkStmt(child, annotated); err != nil {
6284
return err
6385
}
6486
}
6587
for _, c := range st.ElsifClauses {
6688
for _, child := range c.Body {
67-
if err := a.checkStmt(child); err != nil {
89+
if err := a.checkStmt(child, annotated); err != nil {
6890
return err
6991
}
7092
}
7193
}
7294
for _, child := range st.ElseBody {
73-
if err := a.checkStmt(child); err != nil {
95+
if err := a.checkStmt(child, annotated); err != nil {
7496
return err
7597
}
7698
}
7799
case *ast.WhileStmt:
78100
for _, child := range st.Body {
79-
if err := a.checkStmt(child); err != nil {
101+
if err := a.checkStmt(child, annotated); err != nil {
80102
return err
81103
}
82104
}
83105
case *ast.ForStmt:
84106
for _, child := range st.Body {
85-
if err := a.checkStmt(child); err != nil {
107+
if err := a.checkStmt(child, annotated); err != nil {
86108
return err
87109
}
88110
}
@@ -105,8 +127,9 @@ func (a *annotCheck) checkFnExpr(fn *ast.FnExpr) error {
105127
if err := validateFnExprAnnotations(fn, a.sourceFile); err != nil {
106128
return err
107129
}
130+
localAnnots := map[string]bool{}
108131
for _, child := range fn.Body {
109-
if err := a.checkStmt(child); err != nil {
132+
if err := a.checkStmt(child, localAnnots); err != nil {
110133
return err
111134
}
112135
}

0 commit comments

Comments
 (0)