feat: match guards, HOF inference, ownership C.2/C.3, LSP sema hover

Sessions 18–23 quality work:

- B.3c match arm guards + sequential found-flag lower (bootstrap + selfhost)
- Generic HOF type inference (Array/Iter map/filter/fold without type args)
- Pattern binding shadowing via unique C locals (__pN_src)
- Ownership C.2 exclusive &mut data-flow + C.4 goldens; *p= store-through fix
- Ownership C.3 auto-drop on early return/branches: scoped defers, move-on-return,
  Drop monomorphization, materialize return before Drop
- LSP 0.3.0: hover from real sema types
- Examples and QUALITY_PLAN session log; selfhost-loop identical
This commit is contained in:
2026-07-18 21:52:14 +03:00
parent 66f11d1869
commit 3eb1ad3a82
25 changed files with 2246 additions and 457 deletions
+309 -50
View File
@@ -1,4 +1,4 @@
import std/[strformat, tables, strutils]
import std/[strformat, tables, strutils, sets]
import ast, types, scope, source_location, token
type
@@ -44,6 +44,12 @@ type
checkedFunc*: bool ## true inside @[Checked] function
currentFuncIsAsync*: bool ## true inside async func
movedVars*: seq[string] ## variables moved in current checked function
## Active exclusive borrows: source var → borrow site (let-bound &mut lasts for rest of fn)
activeMutBorrows*: Table[string, SourceLocation]
## Active shared borrows of a source var (count of live &T lets)
activeSharedBorrows*: Table[string, int]
## When true, ekIdent skips use-while-borrowed (we're forming `&x` itself)
suppressUseWhileBorrow*: bool
currentRetType*: Type ## return type of the function being checked
closureDepth*: int ## nesting depth inside closures
currentClosureExpr*: Expr ## current closure being analyzed
@@ -101,6 +107,63 @@ proc hasErrors*(res: SemaResult): bool =
return true
return false
# ---------------------------------------------------------------------------
# Borrow checker helpers (@[Checked] exclusive &mut / shared & data-flow)
# ---------------------------------------------------------------------------
proc extractBorrowedIdent*(e: Expr): string =
## Identify the source variable of `&x`, `borrow x`, `borrow &mut x`, etc.
if e == nil: return ""
case e.kind
of ekUnary:
if e.exprUnaryOp == tkAmp and e.exprUnaryOperand != nil:
if e.exprUnaryOperand.kind == ekIdent:
return e.exprUnaryOperand.exprIdent
if e.exprUnaryOperand.kind == ekUnary and e.exprUnaryOperand.exprUnaryOp == tkAmp and
e.exprUnaryOperand.exprUnaryOperand != nil and
e.exprUnaryOperand.exprUnaryOperand.kind == ekIdent:
return e.exprUnaryOperand.exprUnaryOperand.exprIdent
of ekBorrow:
return extractBorrowedIdent(e.exprBorrowOperand)
else:
discard
return ""
proc checkCreateBorrow(sema: var Sema, varName: string, isMut: bool, loc: SourceLocation) =
## Register a long-lived (let-bound) borrow of `varName` in a @[Checked] function.
if not sema.checkedFunc or varName.len == 0:
return
if isMut:
if sema.activeMutBorrows.hasKey(varName):
sema.emitError(loc, &"cannot mutably borrow '{varName}': already mutably borrowed")
return
if sema.activeSharedBorrows.getOrDefault(varName, 0) > 0:
sema.emitError(loc, &"cannot mutably borrow '{varName}' while it is shared-borrowed")
return
sema.activeMutBorrows[varName] = loc
else:
if sema.activeMutBorrows.hasKey(varName):
sema.emitError(loc, &"cannot shared-borrow '{varName}' while it is mutably borrowed")
return
sema.activeSharedBorrows[varName] = sema.activeSharedBorrows.getOrDefault(varName, 0) + 1
proc checkUseWhileBorrowed(sema: var Sema, varName: string, loc: SourceLocation, isWrite: bool) =
## Reject uses of a variable that has an active exclusive (&mut) borrow.
if not sema.checkedFunc or varName.len == 0 or sema.suppressUseWhileBorrow:
return
if sema.activeMutBorrows.hasKey(varName):
let kind = if isWrite: "assign to" else: "use"
sema.emitError(loc, &"cannot {kind} '{varName}' while it is mutably borrowed")
proc checkTempMutBorrow(sema: var Sema, varName: string, loc: SourceLocation) =
## Temporary &mut in a call argument — conflict with existing long-lived borrows.
if not sema.checkedFunc or varName.len == 0:
return
if sema.activeMutBorrows.hasKey(varName):
sema.emitError(loc, &"cannot mutably borrow '{varName}': already mutably borrowed")
elif sema.activeSharedBorrows.getOrDefault(varName, 0) > 0:
sema.emitError(loc, &"cannot mutably borrow '{varName}' while it is shared-borrowed")
# ---------------------------------------------------------------------------
# Generic type inference helpers
# ---------------------------------------------------------------------------
@@ -172,6 +235,14 @@ proc typeToTypeExpr*(t: Type): TypeExpr =
else:
TypeExpr(kind: tekNamed, typeName: "void")
of tkVoid: TypeExpr(kind: tekNamed, typeName: "void")
of tkFunc:
var params: seq[TypeExpr] = @[]
if t.inner.len > 0:
for i in 0 ..< t.inner.len - 1:
params.add(typeToTypeExpr(t.inner[i]))
let ret = typeToTypeExpr(t.inner[^1])
return TypeExpr(kind: tekFunc, funcParams: params, funcRet: ret)
TypeExpr(kind: tekNamed, typeName: "void")
else: TypeExpr(kind: tekNamed, typeName: t.toString)
proc substituteTypeInType(sema: var Sema, t: Type, subst: Table[string, Type]): Type =
@@ -213,58 +284,189 @@ proc substituteTypeInType(sema: var Sema, t: Type, subst: Table[string, Type]):
else:
return t
proc unifyTypeParam(sema: var Sema, pattern: TypeExpr, concrete: Type,
tpNames: HashSet[string],
bindings: var Table[string, Type],
loc: SourceLocation): bool =
## Structural match of a parameter TypeExpr against a concrete argument Type.
## Binds type parameters (names in `tpNames`) into `bindings`.
## Returns false on hard mismatch; true when matching succeeds (unknowns ok).
if pattern == nil:
return true
if concrete == nil or concrete.isUnknown:
return true
case pattern.kind
of tekNamed:
let name = pattern.typeName
# Bare type parameter: T
if name in tpNames and pattern.typeArgs.len == 0:
if bindings.hasKey(name):
let prev = bindings[name]
if prev == concrete:
return true
if concrete.isAssignableTo(prev):
return true
if prev.isAssignableTo(concrete):
bindings[name] = concrete
return true
sema.emitError(loc,
&"conflicting types for type parameter '{name}': " &
&"{prev.toString} vs {concrete.toString}")
return false
bindings[name] = concrete
return true
# Named type with type args: Iter<T>, Array<U>, Map<K,V>
if pattern.typeArgs.len > 0:
if concrete.kind != tkNamed:
return false
# Allow monomorphized names like Iter_int? Prefer structural Iter + inner.
if concrete.name != name and not concrete.name.startsWith(name & "_"):
return false
if concrete.name == name:
if concrete.inner.len < pattern.typeArgs.len:
return false
for i, ta in pattern.typeArgs:
if i >= concrete.inner.len: break
if not sema.unifyTypeParam(ta, concrete.inner[i], tpNames, bindings, loc):
return false
return true
# Monomorphized form Iter_int — best-effort: only if single type arg
if pattern.typeArgs.len == 1 and concrete.name.startsWith(name & "_"):
let suffix = concrete.name[name.len + 1 .. ^1]
# Only bind if pattern arg is a type param
let ta = pattern.typeArgs[0]
if ta != nil and ta.kind == tekNamed and ta.typeName in tpNames and ta.typeArgs.len == 0:
let mono = makeNamed(suffix)
# Prefer known primitives
let prim =
case suffix
of "int": makeInt()
of "bool": makeBool()
of "String", "str": makeStr()
of "float", "float64": makeFloat64()
else: mono
return sema.unifyTypeParam(ta, prim, tpNames, bindings, loc)
return false
# Concrete named type (not a param): int, String, Foo / primitives
let expected =
case name
of "int": makeInt()
of "int8": makeInt8()
of "int16": makeInt16()
of "int32": makeInt32()
of "int64": makeInt64()
of "uint": makeUInt()
of "uint8": makeUInt8()
of "uint16": makeUInt16()
of "uint32": makeUInt32()
of "uint64": makeUInt64()
of "bool": makeBool()
of "float", "float64": makeFloat64()
of "float32": makeFloat32()
of "String", "str": makeStr()
of "void": makeVoid()
else: makeNamed(name)
if concrete == expected:
return true
if concrete.kind == tkNamed and expected.kind == tkNamed:
return concrete.name == expected.name
return concrete.isAssignableTo(expected) or expected.isAssignableTo(concrete)
of tekPointer, tekOwn:
if not concrete.isPointer or concrete.inner.len == 0:
return false
return sema.unifyTypeParam(pattern.pointerPointee, concrete.inner[0], tpNames, bindings, loc)
of tekRef, tekMutRef:
if not concrete.isPointer or concrete.inner.len == 0:
return false
return sema.unifyTypeParam(pattern.pointerPointee, concrete.inner[0], tpNames, bindings, loc)
of tekFunc:
# concrete: tkFunc with inner = params ++ [ret]
if concrete.kind != tkFunc:
return false
let nParams = pattern.funcParams.len
if concrete.inner.len != nParams + 1:
# Allow fat-func mismatch length if unknown
return false
for i, p in pattern.funcParams:
if not sema.unifyTypeParam(p, concrete.inner[i], tpNames, bindings, loc):
return false
if pattern.funcRet != nil:
if not sema.unifyTypeParam(pattern.funcRet, concrete.inner[^1], tpNames, bindings, loc):
return false
return true
of tekSlice:
if not concrete.isSlice or concrete.inner.len == 0:
return false
return sema.unifyTypeParam(pattern.sliceElement, concrete.inner[0], tpNames, bindings, loc)
of tekTuple:
if concrete.kind != tkTuple or concrete.inner.len != pattern.tupleElements.len:
return false
for i, elem in pattern.tupleElements:
if not sema.unifyTypeParam(elem, concrete.inner[i], tpNames, bindings, loc):
return false
return true
of tekPath, tekDynRef, tekSelf:
return true # best-effort skip
proc inferTypeArgs(sema: var Sema, funcDecl: Decl, argTypes: seq[Type],
loc: SourceLocation): seq[TypeExpr] =
## Infer type arguments from argument types for a generic function call.
## Uses structural matching so `*Iter<T>` + `func(T)->U` yield T and U.
## Returns empty seq if inference fails for any type parameter.
result = @[]
var tpNames = initHashSet[string]()
for tp in funcDecl.declFuncTypeParams:
if not tp.isLifetime:
tpNames.incl(tp.name)
var bindings = initTable[string, Type]()
# Lifetime params: mark as found if any ref param uses them
for tp in funcDecl.declFuncTypeParams:
let tpName = tp.name
# Lifetime params are inferred from ref lifetime positions
if tp.isLifetime:
var found = false
for i, param in funcDecl.declFuncParams:
if i >= argTypes.len: break
if param.ptype.kind in {tekRef, tekMutRef} and param.ptype.refLifetime == tpName:
if param.ptype.kind in {tekRef, tekMutRef} and param.ptype.refLifetime == tp.name:
found = true
break
if found:
result.add(TypeExpr(kind: tekNamed, typeName: "lifetime"))
continue
# If not found in refs, treat as uninferrable
if not found:
return @[]
# lifetimes don't go into monomorph name; placeholder
bindings[tp.name] = makeNamed("lifetime")
# Unify each param pattern with the corresponding argument type
for i, param in funcDecl.declFuncParams:
if i >= argTypes.len: break
if param.ptype == nil: continue
var refsTp = false
for n in tpNames:
if typeExprReferencesTypeParam(param.ptype, n):
refsTp = true
break
if not refsTp:
continue
if not sema.unifyTypeParam(param.ptype, argTypes[i], tpNames, bindings, loc):
return @[]
var inferred: Type = nil
for i, param in funcDecl.declFuncParams:
if i >= argTypes.len: break
# Skip pointer params — type param is inside the pointee and we cannot
# structurally extract it (e.g., *Map<K,V> → arg is *Map<int,String>)
if param.ptype.kind in {tekOwn, tekPointer}:
continue
if typeExprReferencesTypeParam(param.ptype, tpName):
var argType = argTypes[i]
# If type param is inside a ref/pointer pointee, unwrap the arg type
if param.ptype.kind in {tekRef, tekMutRef, tekPointer} and
typeExprReferencesTypeParam(param.ptype.pointerPointee, tpName) and
argType.isPointer and argType.inner.len > 0:
argType = argType.inner[0]
if inferred == nil:
inferred = argType
elif inferred != argType:
# Check if one is assignable to the other (wider type wins)
if argTypes[i].isAssignableTo(inferred):
discard # inferred stays the same
elif inferred.isAssignableTo(argTypes[i]):
inferred = argTypes[i]
else:
sema.emitError(loc,
&"conflicting types for type parameter '{tpName}': " &
&"{inferred.toString} vs {argType.toString}")
return @[]
if inferred != nil and not inferred.isUnknown:
result.add(typeToTypeExpr(inferred))
else:
# Cannot infer this type parameter from arguments
# Emit results in decl order
for tp in funcDecl.declFuncTypeParams:
if tp.isLifetime:
result.add(TypeExpr(kind: tekNamed, typeName: "lifetime"))
continue
if not bindings.hasKey(tp.name):
return @[]
let t = bindings[tp.name]
if t == nil or t.isUnknown:
return @[]
result.add(typeToTypeExpr(t))
# ---------------------------------------------------------------------------
# Type resolution from AST TypeExpr
@@ -914,6 +1116,8 @@ proc checkExpr(sema: var Sema, expr: Expr, scope: Scope): Type =
if sema.checkedFunc and expr.exprIdent in sema.movedVars:
sema.emitError(expr.loc, &"use of moved value '{expr.exprIdent}'")
return makeUnknown()
# Exclusive borrow: cannot read original while &mut is live
sema.checkUseWhileBorrowed(expr.exprIdent, expr.loc, isWrite = false)
let sym = scope.lookup(expr.exprIdent)
if sym == nil:
sema.emitError(expr.loc, &"undeclared identifier '{expr.exprIdent}'")
@@ -946,7 +1150,15 @@ proc checkExpr(sema: var Sema, expr: Expr, scope: Scope): Type =
return makeUnknown()
return first.typ
of ekUnary:
let operandType = sema.checkExpr(expr.exprUnaryOperand, scope)
# Forming `&x` must not count as a "use" of x (borrow creation is checked separately)
var operandType: Type
if expr.exprUnaryOp == tkAmp:
let savedSup = sema.suppressUseWhileBorrow
sema.suppressUseWhileBorrow = true
operandType = sema.checkExpr(expr.exprUnaryOperand, scope)
sema.suppressUseWhileBorrow = savedSup
else:
operandType = sema.checkExpr(expr.exprUnaryOperand, scope)
case expr.exprUnaryOp
of tkBang:
if not operandType.isBool:
@@ -1035,7 +1247,16 @@ proc checkExpr(sema: var Sema, expr: Expr, scope: Scope): Type =
let movedIdx = sema.movedVars.find(expr.exprAssignTarget.exprIdent)
if movedIdx >= 0:
sema.movedVars.delete(movedIdx)
let target = sema.checkExpr(expr.exprAssignTarget, scope)
# Cannot assign to var while it is mutably borrowed (single message; suppress rvalue use-check)
sema.checkUseWhileBorrowed(expr.exprAssignTarget.exprIdent, expr.loc, isWrite = true)
var target: Type
if expr.exprAssignTarget != nil and expr.exprAssignTarget.kind == ekIdent:
let savedSup = sema.suppressUseWhileBorrow
sema.suppressUseWhileBorrow = true
target = sema.checkExpr(expr.exprAssignTarget, scope)
sema.suppressUseWhileBorrow = savedSup
else:
target = sema.checkExpr(expr.exprAssignTarget, scope)
let value = sema.checkExpr(expr.exprAssignValue, scope)
if not value.isAssignableTo(target):
sema.emitError(expr.loc, &"cannot assign {value.toString} to {target.toString}")
@@ -1207,8 +1428,16 @@ proc checkExpr(sema: var Sema, expr: Expr, scope: Scope): Type =
for i in 0 ..< argTypes.len:
if expectedParams[i].isMutRef and i < expr.exprCallArgs.len:
let arg = expr.exprCallArgs[i]
if arg.kind == ekUnary and arg.exprUnaryOp == tkAmp and arg.exprUnaryOperand.kind == ekIdent:
mutRefArgs.add((idx: i, name: arg.exprUnaryOperand.exprIdent))
let bname = extractBorrowedIdent(arg)
if bname.len > 0:
mutRefArgs.add((idx: i, name: bname))
# Conflict with long-lived let-bound &mut
sema.checkTempMutBorrow(bname, arg.loc)
elif expectedParams[i].isRef and i < expr.exprCallArgs.len:
let bname = extractBorrowedIdent(expr.exprCallArgs[i])
if bname.len > 0 and sema.activeMutBorrows.hasKey(bname):
sema.emitError(expr.exprCallArgs[i].loc,
&"cannot shared-borrow '{bname}' while it is mutably borrowed")
for i in 0 ..< mutRefArgs.len:
for j in i+1 ..< mutRefArgs.len:
if mutRefArgs[i].name == mutRefArgs[j].name:
@@ -1472,6 +1701,11 @@ proc checkExpr(sema: var Sema, expr: Expr, scope: Scope): Type =
for arm in expr.exprMatchArms:
var armScope = newScope(scope)
sema.extractPatternBindings(arm.pattern, armScope, subjectType)
# Type-check `p if guard` condition (must be bool; sees pattern bindings)
if arm.pattern != nil and arm.pattern.kind == pkGuarded and arm.pattern.patGuardedExpr != nil:
let guardTy = sema.checkExpr(arm.pattern.patGuardedExpr, armScope)
if not guardTy.isBool and not guardTy.isUnknown:
sema.emitError(arm.pattern.patGuardedExpr.loc, "match guard condition must be bool")
let armType = sema.checkExpr(arm.body, armScope)
if resultType.isUnknown:
resultType = armType
@@ -1507,13 +1741,19 @@ proc checkExpr(sema: var Sema, expr: Expr, scope: Scope): Type =
return makePointer(makeVoid())
of ekBorrow:
let operand = sema.checkExpr(expr.exprBorrowOperand, scope)
# borrow &mut expr returns the same type as the original (reference)
# The borrow is tracked in the borrow checker
if sema.checkedFunc and expr.exprBorrowMutable:
# Track: variable "operand" is mutably borrowed here
# For now, just validate the type
discard
return operand
# Explicit `borrow` — track only when bound via let (checkCreateBorrow on skLet).
# Here we validate conflicts for free-standing borrow expressions used as temps.
if sema.checkedFunc:
let bname = extractBorrowedIdent(expr)
if bname.len > 0:
if expr.exprBorrowMutable:
sema.checkTempMutBorrow(bname, expr.loc)
elif sema.activeMutBorrows.hasKey(bname):
sema.emitError(expr.loc,
&"cannot shared-borrow '{bname}' while it is mutably borrowed")
if expr.exprBorrowMutable:
return makeMutRef(operand)
return makeRef(operand)
of ekSpread:
return sema.checkExpr(expr.exprSpreadOperand, scope)
of ekStringInterp:
@@ -1582,6 +1822,19 @@ proc checkStmt(sema: var Sema, stmt: Stmt, scope: Scope): Type =
let initSym = scope.lookup(stmt.stmtLetInit.exprIdent)
if initSym != nil and initSym.isOwn:
sema.movedVars.add(stmt.stmtLetInit.exprIdent)
# Long-lived borrow: `let r: &mut T = &x` / `let r: &T = &x`
if sema.checkedFunc and stmt.stmtLetInit != nil:
let bname = extractBorrowedIdent(stmt.stmtLetInit)
if bname.len > 0:
var isMut = false
if declaredType.isMutRef:
isMut = true
elif declaredType.isRef:
isMut = false
else:
# Untyped let + `&x` is typed as &mut by unary lowering
isMut = initType.isMutRef
sema.checkCreateBorrow(bname, isMut, stmt.stmtLetInit.loc)
return makeVoid()
of skIf:
let condType = sema.checkExpr(stmt.stmtIfCond, scope)
@@ -1631,6 +1884,10 @@ proc checkStmt(sema: var Sema, stmt: Stmt, scope: Scope): Type =
for arm in stmt.stmtMatchArms:
var armScope = newScope(scope)
sema.extractPatternBindings(arm.pattern, armScope, subjectType)
if arm.pattern != nil and arm.pattern.kind == pkGuarded and arm.pattern.patGuardedExpr != nil:
let guardTy = sema.checkExpr(arm.pattern.patGuardedExpr, armScope)
if not guardTy.isBool and not guardTy.isUnknown:
sema.emitError(arm.pattern.patGuardedExpr.loc, "match guard condition must be bool")
discard sema.checkExpr(arm.body, armScope)
return makeVoid()
of skReturn:
@@ -1704,6 +1961,8 @@ proc checkFunc(sema: var Sema, decl: Decl) =
sema.currentFuncIsAsync = decl.declFuncIsAsync
if sema.checkedFunc:
sema.movedVars = @[]
sema.activeMutBorrows = initTable[string, SourceLocation]()
sema.activeSharedBorrows = initTable[string, int]()
var funcScope = newScope(sema.globalScope)
# Add type parameters to type table for resolution
var addedTypeParams: seq[string] = @[]