Skip to content

Commit 502418c

Browse files
committed
tighten annotations for some types
1 parent 0cf4045 commit 502418c

6 files changed

Lines changed: 443 additions & 64 deletions

File tree

compiler/check_mismatch.go

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,60 @@ func compatibleAssignToAnnotation(annot, inferred RugoType) bool {
362362
return annot == inferred
363363
}
364364

365+
// compatibleCallArgToAnnotation is the predicate used at call sites:
366+
// validating an argument's inferred type against the callee's parameter
367+
// annotation. It is strict like compatibleAssignToAnnotation (so a
368+
// `: String` param does NOT silently accept an Integer literal, matching
369+
// the same rule a `x : String = ...` local variable enforces), with one
370+
// runtime-coercion carve-out:
371+
//
372+
// - Integer ↔ Float at the call boundary (codegen inserts
373+
// rugo_to_int / rugo_to_float wrappers, so numeric values flow
374+
// freely between the two annotated numeric types).
375+
// - Bool → Integer and Bool → Float (runtime treats bool as 0/1, and
376+
// the wrappers above handle the conversion).
377+
//
378+
// `Any` annotations and `Unknown`/`Dynamic` inferred types remain
379+
// silent. Unions are checked member-wise: every member must
380+
// independently pass, so an Integer|String value can't sneak into a
381+
// `: Integer` parameter, but Integer|Float into `: Integer` is fine
382+
// because each member is independently compatible (the second via the
383+
// numeric carve-out).
384+
//
385+
// Use this for call-site checks (literal args, variable args) and for
386+
// parameter default-value checks. For *return* values, use the
387+
// permissive compatibleWithAnnotation — codegen really does stringify
388+
// or coerce-to-bool whatever the return slot needs.
389+
func compatibleCallArgToAnnotation(annot, inferred RugoType) bool {
390+
if inferred == TypeUnknown || inferred == TypeDynamic {
391+
return true
392+
}
393+
switch annot {
394+
case TypeDynamic, TypeUnknown:
395+
return true
396+
}
397+
if inferred.IsUnion() {
398+
for _, m := range inferred.Members() {
399+
if !compatibleCallArgToAnnotation(annot, m) {
400+
return false
401+
}
402+
}
403+
return true
404+
}
405+
if annot == inferred {
406+
return true
407+
}
408+
// Single-bit numeric carve-out: Integer ↔ Float, plus Bool into
409+
// either numeric type. Codegen inserts rugo_to_int / rugo_to_float
410+
// at the call boundary so the conversion is well-defined.
411+
if annot == TypeInt || annot == TypeFloat {
412+
if inferred == TypeInt || inferred == TypeFloat || inferred == TypeBool {
413+
return true
414+
}
415+
}
416+
return false
417+
}
418+
365419
// collectLocalAnnots scans a statement list (a function body, top-level
366420
// program, or test/bench block) for `x : T = expr` bindings and adds
367421
// them to dst. First-appearance wins (sticky semantics; re-annotation
@@ -451,10 +505,12 @@ func fileFor(f *ast.FuncDef, fallback string) string {
451505
// (function calls, identifiers) are silently allowed because their value
452506
// could legitimately match the annotation.
453507
//
454-
// The compatibility rule is the *permissive* one (compatibleWithAnnotation),
455-
// matching what call-site checks use: `string`/`bool`/`any` accept
456-
// anything, numeric types are mutually compatible, and `nil`/`array`/`hash`
457-
// only accept their own type.
508+
// The compatibility rule is the strict-with-numeric-carve-out one
509+
// (compatibleCallArgToAnnotation), matching call-site checks:
510+
// `String`/`Bool` annotations only accept their own type, the numeric
511+
// family (`Integer`/`Float`/`Bool`) flows freely between numeric
512+
// annotations, and `Any` accepts anything. A `: String` default value
513+
// of `42` is therefore a guaranteed type-violation.
458514
//
459515
// Both top-level FuncDefs and FnExpr lambdas (anywhere they appear) are
460516
// validated. Nested FuncDefs are reachable through walkStmtExprs's
@@ -560,7 +616,7 @@ func checkParamDefault(p ast.Param, sourceFile string, line int) error {
560616
if !isLit {
561617
return nil
562618
}
563-
if compatibleWithAnnotation(annot, litType) {
619+
if compatibleCallArgToAnnotation(annot, litType) {
564620
return nil
565621
}
566622
return &ast.UserError{Msg: fmt.Sprintf(

compiler/check_mismatch_calls.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,11 @@ import (
1818
// variable may legitimately hold a value of the annotated type even when
1919
// the inferrer cannot prove it.
2020
//
21-
// The compatibility rule is the permissive one (compatibleWithAnnotation),
22-
// the same predicate used at return sites: numeric types are mutually
23-
// compatible, `string` / `bool` annotations accept anything, and `any`
24-
// accepts anything. This mirrors what codegen actually does at the call
25-
// boundary (rugo_to_int / rugo_to_float / rugo_to_string / rugo_to_bool
26-
// coercions and numeric casts).
21+
// The compatibility rule is compatibleCallArgToAnnotation — strict like
22+
// the variable-assignment rule (`x : String = 42` errors, and so does
23+
// `f(x : String); f(42)`), with a numeric carve-out for Integer/Float/Bool
24+
// because codegen inserts rugo_to_int / rugo_to_float wrappers at the
25+
// call boundary.
2726
//
2827
// Module / method calls (`str.upper(...)`, `obj.method(...)`) are skipped
2928
// because they have no Rugo-level parameter annotations to compare. Calls
@@ -471,7 +470,7 @@ func (c *callChecker) checkArgs(args []ast.Expr, params []ast.Param, calleeName
471470
}
472471
// Path 1: literal argument — flag immediately on concrete mismatch.
473472
if argType, isLit := literalType(arg); isLit {
474-
if compatibleWithAnnotation(annot, argType) {
473+
if compatibleCallArgToAnnotation(annot, argType) {
475474
continue
476475
}
477476
return &ast.UserError{Msg: fmt.Sprintf(
@@ -492,7 +491,7 @@ func (c *callChecker) checkArgs(args []ast.Expr, params []ast.Param, calleeName
492491
if !argType.IsResolved() {
493492
continue
494493
}
495-
if compatibleWithAnnotation(annot, argType) {
494+
if compatibleCallArgToAnnotation(annot, argType) {
496495
continue
497496
}
498497
return &ast.UserError{Msg: fmt.Sprintf(

compiler/check_mismatch_calls_test.go

Lines changed: 186 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -158,16 +158,6 @@ def f(a : Float) : Float
158158
return a + 1.0
159159
end
160160
puts(f(2))
161-
`,
162-
shouldError: false,
163-
},
164-
{
165-
name: "Integer literal to string param is permitted",
166-
source: `
167-
def f(a : String) : String
168-
return a
169-
end
170-
puts(f(42))
171161
`,
172162
shouldError: false,
173163
},
@@ -180,18 +170,6 @@ end
180170
puts(f(nil))
181171
puts(f("hi"))
182172
puts(f([1, 2]))
183-
`,
184-
shouldError: false,
185-
},
186-
{
187-
name: "anything to bool-annotated param is permitted",
188-
source: `
189-
def f(a : Bool) : Bool
190-
return a
191-
end
192-
puts(f(0))
193-
puts(f("hi"))
194-
puts(f([1, 2]))
195173
`,
196174
shouldError: false,
197175
},
@@ -384,6 +362,192 @@ puts(task.value)
384362
wantSubstr: "cannot pass String literal as argument 1 to 'square'",
385363
shouldError: true,
386364
},
365+
// ---------------------------------------------------------------
366+
// Strict call-site rule: String / Bool / Nil params no longer
367+
// accept arbitrary types (matches the strict variable-annotation
368+
// rule). Numeric carve-out preserved (Int↔Float, Bool→numeric).
369+
// ---------------------------------------------------------------
370+
{
371+
name: "Integer literal to string param is flagged (strict)",
372+
source: `
373+
def f(a : String) : String
374+
return a
375+
end
376+
puts(f(42))
377+
`,
378+
wantSubstr: "cannot pass Integer literal as argument 1 to 'f' (parameter 'a' declared as String)",
379+
shouldError: true,
380+
},
381+
{
382+
name: "Float literal to string param is flagged (strict)",
383+
source: `
384+
def f(a : String) : String
385+
return a
386+
end
387+
puts(f(3.14))
388+
`,
389+
wantSubstr: "cannot pass Float literal as argument 1 to 'f' (parameter 'a' declared as String)",
390+
shouldError: true,
391+
},
392+
{
393+
name: "Bool literal to string param is flagged (strict)",
394+
source: `
395+
def f(a : String) : String
396+
return a
397+
end
398+
puts(f(true))
399+
`,
400+
wantSubstr: "cannot pass Bool literal as argument 1 to 'f' (parameter 'a' declared as String)",
401+
shouldError: true,
402+
},
403+
{
404+
name: "Nil literal to string param is flagged (strict)",
405+
source: `
406+
def f(a : String) : String
407+
return a
408+
end
409+
puts(f(nil))
410+
`,
411+
wantSubstr: "cannot pass Nil literal as argument 1 to 'f' (parameter 'a' declared as String)",
412+
shouldError: true,
413+
},
414+
{
415+
name: "Array literal to string param is flagged (strict)",
416+
source: `
417+
def f(a : String) : String
418+
return a
419+
end
420+
puts(f([1, 2]))
421+
`,
422+
wantSubstr: "cannot pass Array literal as argument 1 to 'f' (parameter 'a' declared as String)",
423+
shouldError: true,
424+
},
425+
{
426+
name: "String literal to bool param is flagged (strict)",
427+
source: `
428+
def f(a : Bool) : Bool
429+
return a
430+
end
431+
puts(f("hi"))
432+
`,
433+
wantSubstr: "cannot pass String literal as argument 1 to 'f' (parameter 'a' declared as Bool)",
434+
shouldError: true,
435+
},
436+
{
437+
name: "Integer literal to bool param is flagged (strict)",
438+
source: `
439+
def f(a : Bool) : Bool
440+
return a
441+
end
442+
puts(f(0))
443+
`,
444+
wantSubstr: "cannot pass Integer literal as argument 1 to 'f' (parameter 'a' declared as Bool)",
445+
shouldError: true,
446+
},
447+
{
448+
name: "Array literal to bool param is flagged (strict)",
449+
source: `
450+
def f(a : Bool) : Bool
451+
return a
452+
end
453+
puts(f([1, 2]))
454+
`,
455+
wantSubstr: "cannot pass Array literal as argument 1 to 'f' (parameter 'a' declared as Bool)",
456+
shouldError: true,
457+
},
458+
{
459+
name: "Tier 3: typed Integer variable to string param is flagged",
460+
source: `
461+
def f(a : String) : String
462+
return a
463+
end
464+
x : Integer = 42
465+
puts(f(x))
466+
`,
467+
wantSubstr: "cannot pass Integer value as argument 1 to 'f' (parameter 'a' declared as String)",
468+
shouldError: true,
469+
},
470+
{
471+
name: "Tier 3: typed String variable to bool param is flagged",
472+
source: `
473+
def f(a : Bool) : Bool
474+
return a
475+
end
476+
x : String = "hi"
477+
puts(f(x))
478+
`,
479+
wantSubstr: "cannot pass String value as argument 1 to 'f' (parameter 'a' declared as Bool)",
480+
shouldError: true,
481+
},
482+
// ---------------------------------------------------------------
483+
// Numeric carve-out: still permissive at call sites for the
484+
// Integer/Float/Bool numeric family.
485+
// ---------------------------------------------------------------
486+
{
487+
name: "Integer literal to float param is permitted (numeric carve-out)",
488+
source: `
489+
def f(a : Float) : Float
490+
return a + 1.0
491+
end
492+
puts(f(2))
493+
`,
494+
shouldError: false,
495+
},
496+
{
497+
name: "Float literal to int param is permitted (numeric carve-out)",
498+
source: `
499+
def f(a : Integer) : Integer
500+
return a + 1
501+
end
502+
puts(f(2.5))
503+
`,
504+
shouldError: false,
505+
},
506+
{
507+
name: "Bool literal to int param is permitted (numeric carve-out)",
508+
source: `
509+
def f(a : Integer) : Integer
510+
return a + 1
511+
end
512+
puts(f(true))
513+
`,
514+
shouldError: false,
515+
},
516+
// ---------------------------------------------------------------
517+
// Any annot still accepts anything.
518+
// ---------------------------------------------------------------
519+
{
520+
name: "anything to any-annotated param is still permitted",
521+
source: `
522+
def f(x : Any) : Any
523+
return x
524+
end
525+
puts(f(nil))
526+
puts(f("hi"))
527+
puts(f([1, 2]))
528+
`,
529+
shouldError: false,
530+
},
531+
{
532+
name: "Same-type matches still pass: String to String",
533+
source: `
534+
def f(s : String) : String
535+
return s
536+
end
537+
puts(f("hello"))
538+
`,
539+
shouldError: false,
540+
},
541+
{
542+
name: "Same-type matches still pass: Bool to Bool",
543+
source: `
544+
def f(b : Bool) : Bool
545+
return !b
546+
end
547+
puts(f(true))
548+
`,
549+
shouldError: false,
550+
},
387551
}
388552

389553
for _, tc := range cases {

0 commit comments

Comments
 (0)