Skip to content

Commit db6e197

Browse files
committed
feat: Early-exit narrowing, assert narrowing and short-circuit expression narrowing
1 parent f1dfc0d commit db6e197

4 files changed

Lines changed: 377 additions & 12 deletions

File tree

internal/compiler/typecheck/checker.go

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,11 @@ func (c *checker) walkStatement(s ast.Statement) {
283283
c.env.pop()
284284
case *ast.ExpressionStatement:
285285
c.walkExpressionDiscard(n.Expression)
286+
// `assert(cond)` only returns when cond held, so its positive
287+
// narrowing applies to the rest of the block.
288+
if cond, ok := assertCondition(n.Expression); ok {
289+
c.applyRefinement(c.refine(cond, true))
290+
}
286291
case *ast.DeferStatement:
287292
// The deferred call is checked like any other call statement; it
288293
// produces no value the surrounding scope can observe.
@@ -378,7 +383,9 @@ func (c *checker) walkAssignStatement(s *ast.AssignStatement) {
378383
for i, t := range s.Targets {
379384
switch tgt := t.(type) {
380385
case *ast.Identifier:
381-
declared, ok := c.env.lookup(tgt.Name)
386+
// Check against the declared type, not any narrowing shadow —
387+
// `s = nil` inside `if s ~= nil then` is legal for a `string?`.
388+
declared, ok := c.env.lookupDeclared(tgt.Name)
382389
if !ok {
383390
// First-time global write: bind to the RHS type so later
384391
// reads see it. Matches Lua's "globals materialize on
@@ -389,6 +396,9 @@ func (c *checker) walkAssignStatement(s *ast.AssignStatement) {
389396
if !assignable(rhs[i], declared) {
390397
c.errAssign(s.Line(), rhs[i], declared)
391398
}
399+
// The value changed; any active narrowing must absorb the new
400+
// type or it would keep vouching for the old one.
401+
c.env.widenRefined(tgt.Name, rhs[i])
392402
case *ast.IndexExpression:
393403
// Field assignment to a table. We currently don't enforce
394404
// shape conformance on writes (most Lua tables are open),
@@ -408,7 +418,14 @@ func (c *checker) walkIfStatement(s *ast.IfStatement) {
408418
// earlier condition was false — exactly Lua's evaluation order. The
409419
// then-branch of each clause gets its own child frame on top.
410420
c.env.push()
411-
defer c.env.pop()
421+
422+
// Early-exit narrowing: when a leading prefix of clauses always
423+
// terminates (`if s == nil then return end`), falling past the whole
424+
// statement proves each of those conditions was false, so their
425+
// negations outlive the `end`. Only a prefix qualifies — once a clause
426+
// can fall through, later conditions may never have been evaluated.
427+
var persist []refinement
428+
prefixTerminates := true
412429

413430
for _, cl := range s.Clauses {
414431
// Walk the condition once (in the already-narrowed scope) for error
@@ -423,14 +440,28 @@ func (c *checker) walkIfStatement(s *ast.IfStatement) {
423440

424441
// Fold this clause's "condition is false" narrowing into the
425442
// accumulator so subsequent branches see it.
426-
c.applyRefinement(c.refine(cl.Condition, false))
443+
negR := c.refine(cl.Condition, false)
444+
c.applyRefinement(negR)
445+
446+
if prefixTerminates {
447+
if blockTerminates(cl.Body) {
448+
persist = append(persist, negR)
449+
} else {
450+
prefixTerminates = false
451+
}
452+
}
427453
}
428454

429455
if s.Else != nil {
430456
c.env.push()
431457
c.walkBlock(s.Else)
432458
c.env.pop()
433459
}
460+
461+
c.env.pop()
462+
for _, r := range persist {
463+
c.applyRefinement(r)
464+
}
434465
}
435466

436467
func (c *checker) walkNumericFor(s *ast.NumericForStatement) {
@@ -934,7 +965,26 @@ func (c *checker) checkNamedStructCall(call *ast.CallExpression, sc *StructCtor)
934965

935966
func (c *checker) typeOfBinary(e *ast.BinaryExpression) *Type {
936967
left := c.typeOfExpression(e.Left)
937-
right := c.typeOfExpression(e.Right)
968+
969+
// and/or short-circuit, so their RHS only evaluates under the LHS's
970+
// truthy (and) / falsy (or) outcome — type it in a frame carrying that
971+
// narrowing: `s ~= nil and #s`, `x or default`.
972+
var right *Type
973+
switch e.Op {
974+
case "and":
975+
c.env.push()
976+
c.applyRefinement(c.refine(e.Left, true))
977+
right = c.typeOfExpression(e.Right)
978+
c.env.pop()
979+
case "or":
980+
c.env.push()
981+
c.applyRefinement(c.refine(e.Left, false))
982+
right = c.typeOfExpression(e.Right)
983+
c.env.pop()
984+
default:
985+
right = c.typeOfExpression(e.Right)
986+
}
987+
938988
switch e.Op {
939989
case "+", "-", "*", "/", "//", "%", "^", "&", "|", "~", "<<", ">>":
940990
c.requireNumber(e.Line(), left, right)
@@ -965,11 +1015,22 @@ func (c *checker) typeOfBinary(e *ast.BinaryExpression) *Type {
9651015
}
9661016
return booleanT
9671017
case "and":
968-
// `a and b` returns a if falsy, else b. The result type is the
969-
// union; for a typed-correctness perspective this is good enough.
970-
return NewUnion(left, right)
1018+
// `a and b` yields a only when a is falsy — the truthy members of
1019+
// a's type can't reach the result. (false can't be split off from
1020+
// boolean, so a boolean member survives whole.)
1021+
falsy := keepKinds(left, KindNil, KindBoolean)
1022+
if falsy.Kind == KindNever {
1023+
return right
1024+
}
1025+
return NewUnion(falsy, right)
9711026
case "or":
972-
return NewUnion(left, right)
1027+
// `a or b` yields a only when a is truthy — nil can't survive,
1028+
// which is what makes `x or default` a non-optional.
1029+
truthy := removeKind(left, KindNil)
1030+
if truthy.Kind == KindNever {
1031+
return right
1032+
}
1033+
return NewUnion(truthy, right)
9731034
}
9741035
return anyT
9751036
}

internal/compiler/typecheck/env.go

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ type env struct {
3434

3535
type frame struct {
3636
bindings map[string]*Type
37+
38+
// refined marks names whose binding in this frame is a narrowing shadow
39+
// installed by applyRefinement, not a declaration. Assignment checking
40+
// looks through these to the declared type (lookupDeclared), and an
41+
// assignment widens them in place (widenRefined) so a stale narrowing
42+
// can't outlive the value it described. Lazily allocated.
43+
refined map[string]bool
3744
}
3845

3946
func newEnv() *env {
@@ -57,12 +64,61 @@ func (e *env) pop() {
5764

5865
// define binds a name in the innermost frame, shadowing any outer binding.
5966
// Re-defining within the same frame replaces the slot — matching Lua's
60-
// `local x ... local x` shadowing semantics.
67+
// `local x ... local x` shadowing semantics. A real declaration also clears
68+
// any refinement mark left on the slot: `local s = ...` starts fresh.
6169
func (e *env) define(name string, t *Type) {
6270
if len(e.frames) == 0 {
6371
return
6472
}
65-
e.frames[len(e.frames)-1].bindings[name] = t
73+
f := &e.frames[len(e.frames)-1]
74+
f.bindings[name] = t
75+
delete(f.refined, name)
76+
}
77+
78+
// defineRefined binds a narrowing shadow in the innermost frame. It types
79+
// exactly like define for lookup, but is invisible to lookupDeclared and
80+
// mutable by widenRefined.
81+
func (e *env) defineRefined(name string, t *Type) {
82+
if len(e.frames) == 0 {
83+
return
84+
}
85+
f := &e.frames[len(e.frames)-1]
86+
f.bindings[name] = t
87+
if f.refined == nil {
88+
f.refined = map[string]bool{}
89+
}
90+
f.refined[name] = true
91+
}
92+
93+
// lookupDeclared returns the innermost binding that is a declaration,
94+
// seeing through refinement shadows. Assignments are checked against this —
95+
// `s = nil` inside `if s ~= nil then` is legal because the *declared* type
96+
// is `string?`, whatever the branch narrowed `s` to.
97+
func (e *env) lookupDeclared(name string) (*Type, bool) {
98+
for i := len(e.frames) - 1; i >= 0; i-- {
99+
f := e.frames[i]
100+
if t, ok := f.bindings[name]; ok {
101+
if f.refined[name] {
102+
continue
103+
}
104+
return t, true
105+
}
106+
}
107+
return e.lookup(name)
108+
}
109+
110+
// widenRefined folds an assigned type into every refinement shadow of
111+
// `name`, so a narrowing can't keep claiming the pre-assignment type after
112+
// `s = nil`. Widening with a union (rather than replacing) keeps outer
113+
// shadows sound when the assignment sits in a deeper branch that may not
114+
// execute on every path that reaches them.
115+
func (e *env) widenRefined(name string, t *Type) {
116+
for i := range e.frames {
117+
f := &e.frames[i]
118+
if f.refined[name] {
119+
f.bindings[name] = NewUnion(f.bindings[name], t)
120+
}
121+
}
66122
}
67123

68124
// lookup walks innermost-to-outermost. Returns the bound type plus true on

internal/compiler/typecheck/refine.go

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ package typecheck
1919
// - and / or: conjunction propagates to the then-branch; disjunction
2020
// propagates to the else-branch (the soundly-decidable
2121
// halves of De Morgan)
22+
// - early exit: `if s == nil then return end` — when a leading prefix
23+
// of if-clauses always terminates (return/break/continue/
24+
// throw/error()), their negations persist after the `end`
25+
// - assert: `assert(cond)` applies cond's positive narrowing to the
26+
// rest of the block
27+
// - expressions: the RHS of `a and b` is typed under a's positive
28+
// narrowing; the RHS of `a or b` under its negative
29+
//
30+
// Assignment interplay: narrowing shadows are marked in the env (see
31+
// defineRefined) so an assignment is checked against the *declared* type and
32+
// then widens every shadow with the assigned type — a refinement never
33+
// outlives the value it described.
2234
//
2335
// Only simple identifiers are refinable — field paths like `x.y` are not
2436
// tracked (they can be invalidated by aliasing, and Luau itself only
@@ -195,10 +207,12 @@ func (c *checker) refineNilGuard(name string, eq bool) refinement {
195207
}
196208

197209
// applyRefinement installs a refinement into the current (innermost) env
198-
// frame, shadowing outer bindings for the lifetime of that frame.
210+
// frame, shadowing outer bindings for the lifetime of that frame. The
211+
// bindings are marked as refinement shadows so assignment checking can see
212+
// through them to the declared type.
199213
func (c *checker) applyRefinement(r refinement) {
200214
for name, t := range r {
201-
c.env.define(name, t)
215+
c.env.defineRefined(name, t)
202216
}
203217
}
204218

@@ -341,6 +355,80 @@ func kindForTypeString(s string) (Kind, bool) {
341355
return 0, false
342356
}
343357

358+
// Terminator analysis (early-exit narrowing)
359+
360+
// blockTerminates reports whether a block never falls through: every path
361+
// through it ends in return/break/continue/throw or a call to error().
362+
// Used by walkIfStatement to persist a clause's negation past the `end` —
363+
// `if s == nil then return end` leaves s non-nil for the rest of the block.
364+
//
365+
// goto is deliberately NOT a terminator: its label may sit later in the
366+
// same block, and statements after the label would then be reached with the
367+
// persisted narrowing wrongly in force.
368+
func blockTerminates(b *ast.Block) bool {
369+
if b == nil {
370+
return false
371+
}
372+
if b.Return != nil {
373+
return true
374+
}
375+
if len(b.Statements) == 0 {
376+
return false
377+
}
378+
return statementTerminates(b.Statements[len(b.Statements)-1])
379+
}
380+
381+
func statementTerminates(s ast.Statement) bool {
382+
switch n := s.(type) {
383+
case *ast.ReturnStatement, *ast.BreakStatement, *ast.ContinueStatement,
384+
*ast.ThrowStatement:
385+
return true
386+
case *ast.ExpressionStatement:
387+
return isErrorCall(n.Expression)
388+
case *ast.DoStatement:
389+
return blockTerminates(n.Body)
390+
case *ast.IfStatement:
391+
// An if terminates only when every arm does — which requires an
392+
// else, or the fall-through path escapes.
393+
if n.Else == nil || !blockTerminates(n.Else) {
394+
return false
395+
}
396+
for _, cl := range n.Clauses {
397+
if !blockTerminates(cl.Body) {
398+
return false
399+
}
400+
}
401+
return true
402+
}
403+
return false
404+
}
405+
406+
// isErrorCall matches a bare `error(...)` call statement.
407+
func isErrorCall(e ast.Expression) bool {
408+
call, ok := e.(*ast.CallExpression)
409+
if !ok {
410+
return false
411+
}
412+
fn, ok := call.Func.(*ast.Identifier)
413+
return ok && fn.Name == "error"
414+
}
415+
416+
// assertCondition returns the condition argument when `e` is a call of the
417+
// form `assert(cond, ...)`. The statement walker applies cond's positive
418+
// narrowing to the rest of the block: control only continues past an assert
419+
// that held.
420+
func assertCondition(e ast.Expression) (ast.Expression, bool) {
421+
call, ok := e.(*ast.CallExpression)
422+
if !ok || len(call.Args) < 1 {
423+
return nil, false
424+
}
425+
fn, ok := call.Func.(*ast.Identifier)
426+
if !ok || fn.Name != "assert" {
427+
return nil, false
428+
}
429+
return call.Args[0], true
430+
}
431+
344432
// AST shape helpers
345433

346434
// typeGuardTarget returns the identifier name `x` when `e` is a call of the

0 commit comments

Comments
 (0)