feat(lsp): report nullable flow and pointer/value mismatches

Returns, call arguments (from the callee signature) and constructor arguments (from __init__ or field order) now flag a nullable value flowing into a non-nullable slot, and a pointer value stored into a value-typed target is reported like the compiler does.
This commit is contained in:
TermiNexus committed 2026-11-03 19:34:27 +08:00
1 parent 3bc7afaf64
commit 295d78aa56
1 file changed
+208 -9
+208 -9
View File
@@ -94,6 +94,35 @@ def _AnnIsPointerLike(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
return 0
def _DetailIsPointer(d: str) -> int:
"""1 when a symbol detail names a pointer/owned type (`U*`, `own U`, `PtrT[...]`)."""
if d is None:
return 0
s: str = d
while string.strlen(s) > 0 and s[0] == ' ':
s += 1
n: sizet = string.strlen(s)
if n == 0:
return 0
if s[n - 1] == '*':
return 1
if string.strncmp(s, "own ", 4) == 0:
return 1
if string.strstr(s, "PtrT[") is not None:
return 1
return 0
def _ValueIsPointer(pool: memhub.MemManager*, a: LspSymbols.LspAnalysis*,
e: ast.AST*) -> int:
"""1 when the value expression yields a pointer (a `new` result or a pointer binding)."""
if e is None:
return 0
if e.kind() == ast.ASTKind.New:
return 1
return _DetailIsPointer(_SymDetailAt(pool, a, e))
def _AnnIsOwn(ann: ast.AST*) -> int:
node: ast.AST* = ann
depth: int = 0
@@ -1155,6 +1184,17 @@ def _WalkChecksExprs(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
if occ_u is not None and occ_u.sym is None and occ_u.head is None and occ_u.builtin_type == 0:
_EmitTypeError(pool, doc, e, f"unknown name '{nm_m.id}'", errors, 2)
return
if k == ast.ASTKind.New:
nw: ast.New* = ast.New*(e)
_CheckCtorArgs(pool, doc, a, ws, table_pool, nw.func, nw.args, errors)
if nw.func is not None:
_WalkChecksExprs(pool, doc, a, ws, table_pool, nw.func, errors)
if nw.args is not None:
i_nw: int = 0
while i_nw < int(len(nw.args)):
_WalkChecksExprs(pool, doc, a, ws, table_pool, nw.args.get(sizet(i_nw)), errors)
i_nw += 1
return
if k == ast.ASTKind.Call:
call: ast.Call* = ast.Call*(e)
_CheckCall(pool, doc, a, ws, table_pool, call, errors)
@@ -1266,6 +1306,142 @@ def _WalkChecksExprs(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
return
def _ParamNullFlags(pool: memhub.MemManager*, detail: str) -> str:
"""Per-parameter nullability from a `def f(a: T, b: U?)` header.
'1' = annotation contains `?`, '0' = annotation without `?`, '2' = no annotation.
"""
if detail is None:
return None
lp: str = string.strchr(detail, '(')
if lp is None:
return None
n: sizet = string.strlen(detail)
start: sizet = sizet(uint64(lp) - uint64(detail)) + 1
if start >= n:
return None
buf: bytes = pool.alloc(n + 2)
if buf is None:
return None
cnt: int = 0
i: sizet = start
seg_len: int = 0
depth: int = 0
in_ann: int = 0
seg_null: int = 0
while i < n:
c: int = int(detail[i])
if depth == 0 and (c == ',' or c == ')'):
if seg_len > 0:
if in_ann == 0: buf[cnt] = 50 # '2'
elif seg_null != 0: buf[cnt] = 49 # '1'
else: buf[cnt] = 48 # '0'
cnt += 1
if c == ')':
break
seg_len = 0
in_ann = 0
seg_null = 0
i += 1
continue
if c == '(' or c == '[' or c == '{':
depth += 1
elif c == ')' or c == ']' or c == '}':
if depth > 0: depth -= 1
elif c == ':' and depth == 0:
in_ann = 1
elif c == '?' and in_ann != 0:
seg_null = 1
elif c != ' ' and c != '\t':
seg_len += 1
i += 1
buf[cnt] = 0
return str(buf)
def _ClassSymbolByName(a: LspSymbols.LspAnalysis*, name: str) -> LspSymbols.LspSymbol*:
"""Class symbol (with its own scope) declared in this document, if any."""
if a is None or name is None or a.symbols is None:
return None
i: int = 0
while i < int(len(a.symbols)):
s: LspSymbols.LspSymbol* = a.symbols.get(sizet(i))
i += 1
if s is not None and s.kind == LspSymbols.LSP_KIND_CLASS and s.name is not None \
and string.strcmp(s.name, name) == 0:
return s
return None
def _CheckCtorArgs(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
a: LspSymbols.LspAnalysis*, ws: LspWorkspace.LspWorkspace*,
table_pool: memhub.MemManager*, func: ast.AST*,
args: list[ast.AST*]*, errors: list[ast.ParseError*]*):
"""Strict nullable flow for `new T(x)`: match args against `__init__` params or field order."""
if func is None or args is None:
return
cname: str = None
if func.kind() == ast.ASTKind.Name:
cname = (ast.Name*)(func).id
elif func.kind() == ast.ASTKind.Attribute:
cname = (ast.Attribute*)(func).attr
if cname is None:
return
cs: LspSymbols.LspSymbol* = _ClassSymbolByName(a, cname)
if cs is None or cs.own_scope is None:
return
argc: int = int(len(args))
if argc == 0:
return
init_s: LspSymbols.LspSymbol* = LspSymbols._FindSymbol(cs.own_scope, "__init__")
if init_s is not None:
pf: str = _ParamNullFlags(pool, init_s.detail)
if pf is None:
return
i: int = 0
while i < argc:
arg: ast.AST* = args.get(sizet(i))
pidx: int = i + 1 # skip self
i += 1
if arg is None or sizet(pidx) >= string.strlen(pf):
continue
if int(pf[sizet(pidx)]) != 48:
continue
if _NullableDetail(_SymDetailAt(pool, a, arg)) == 0:
continue
msg: bytes = pool.alloc(sizet(320))
if msg is not None:
stdio.snprintf(msg, sizet(320),
"constructor argument %d of '%s' is not nullable: a nullable value may be None; declare the parameter/field as `T?` or unwrap it with `match` first",
i, cname)
_EmitTypeError(pool, doc, arg, str(msg), errors)
return
if cs.own_scope.syms is None:
return
syms: list[LspSymbols.LspSymbol*]* = cs.own_scope.syms
fi: int = 0
si: int = 0
while si < int(len(syms)) and fi < argc:
s: LspSymbols.LspSymbol* = syms.get(sizet(si))
si += 1
if s is None or s.kind != LspSymbols.LSP_KIND_FIELD:
continue
arg: ast.AST* = args.get(sizet(fi))
fi += 1
if arg is None:
continue
if s.detail is not None and string.strchr(s.detail, '?') is not None:
continue
if _NullableDetail(_SymDetailAt(pool, a, arg)) == 0:
continue
msg2: bytes = pool.alloc(sizet(320))
if msg2 is not None:
stdio.snprintf(msg2, sizet(320),
"constructor argument %d of '%s' is not nullable: a nullable value may be None; declare the parameter/field as `T?` or unwrap it with `match` first",
fi, cname)
_EmitTypeError(pool, doc, arg, str(msg2), errors)
def _CheckCall(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
a: LspSymbols.LspAnalysis*, ws: LspWorkspace.LspWorkspace*,
table_pool: memhub.MemManager*, call: ast.Call*,
@@ -1309,6 +1485,7 @@ def _CheckCall(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
fname: str = t.name
if fname is None:
fname = "function"
pflags: str = _ParamNullFlags(pool, t.detail)
if argc > param_n and sig.has_vararg == 0:
msg: bytes = pool.alloc(sizet(256))
if msg is not None:
@@ -1339,13 +1516,24 @@ def _CheckCall(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
stdio.snprintf(ctx, sizet(128), "argument %d of '%s': ", ai + 1, fname)
context = str(ctx)
_CheckAssignValue(pool, doc, a, ws, table_pool, pt, arg, context, errors)
# Strict nullable flow: a nullable binding/field must not fill a non-nullable parameter.
if pflags is not None and arg is not None and sizet(pi) < string.strlen(pflags) \
and int(pflags[sizet(pi)]) == 48 \
and _NullableDetail(_SymDetailAt(pool, a, arg)) != 0:
msg_n: bytes = pool.alloc(sizet(320))
if msg_n is not None:
stdio.snprintf(msg_n, sizet(320),
"argument %d of '%s' is not nullable: a nullable value may be None; declare it as `T?` or unwrap it with `match` first",
ai + 1, fname)
_EmitTypeError(pool, doc, arg, str(msg_n), errors)
ai += 1
def _WalkChecks(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
a: LspSymbols.LspAnalysis*, ws: LspWorkspace.LspWorkspace*,
table_pool: memhub.MemManager*, stmts: list[ast.AST*]*,
errors: list[ast.ParseError*]*, ret_t: LspTypes.LspType* = None):
errors: list[ast.ParseError*]*, ret_t: LspTypes.LspType* = None,
ret_ann: str = None):
if stmts is None:
return
i: int = 0
@@ -1363,14 +1551,15 @@ def _WalkChecks(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
fd: ast.FunctionDef* = ast.FunctionDef*(s)
_MoveReset(pool)
fn_ret: LspTypes.LspType* = None
fn_ret_ann: str = None
rsl: int = 0
rsc: int = 0
rel: int = 0
rec: int = 0
if _AnnotationSpan(doc, fd, &rsl, &rsc, &rel, &rec) != 0:
fn_ret = LspTypes.ParseType(
pool, LspTypes._Trim(pool, LspSource.SliceBytes(pool, doc, rsl, rsc, rel, rec)))
_WalkChecks(pool, doc, a, ws, table_pool, fd.children, errors, fn_ret)
fn_ret_ann = LspTypes._Trim(pool, LspSource.SliceBytes(pool, doc, rsl, rsc, rel, rec))
fn_ret = LspTypes.ParseType(pool, fn_ret_ann)
_WalkChecks(pool, doc, a, ws, table_pool, fd.children, errors, fn_ret, fn_ret_ann)
continue
if k == ast.ASTKind.ClassDef:
_WalkChecks(pool, doc, a, ws, table_pool, s.children, errors)
@@ -1386,11 +1575,11 @@ def _WalkChecks(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
_EmitTypeError(pool, doc, an.value,
"a nullable value may be None; declare the target as `T?` or unwrap it with `match` first", errors)
_WalkChecksExprs(pool, doc, a, ws, table_pool, an.value, errors)
if an.value is not None and an.value.kind() == ast.ASTKind.Name:
sn_v: ast.Name* = ast.Name*(an.value)
if sn_v.id is not None and _OwnHas(sn_v.id) != 0 and _AnnIsPointerLike(pool, doc, an) == 0:
_EmitTypeError(pool, doc, an.value,
"a pointer/`own` value cannot be stored into a value-typed variable; declare the target as `own T` / `T*`, or dereference explicitly (`*p`)", errors)
if an.value is not None and _AnnIsPointerLike(pool, doc, an) == 0 \
and _NullableDetail(_SymDetailAt(pool, a, an.value)) == 0 \
and _ValueIsPointer(pool, a, an.value) != 0:
_EmitTypeError(pool, doc, an.value,
"a pointer/`own` value cannot be stored into a value-typed variable; declare the target as `own T` / `T*`, or dereference explicitly (`*p`)", errors)
if an.target is not None and an.target.kind() == ast.ASTKind.Name:
tn: ast.Name* = ast.Name*(an.target)
if tn.id is not None:
@@ -1419,6 +1608,11 @@ def _WalkChecks(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
and _NullableDetail(_SymDetailAt(pool, a, asn.value)) != 0:
_EmitTypeError(pool, doc, asn.value,
"a nullable value may be None; declare the target as `T?` or unwrap it with `match` first", errors)
if tt.detail is not None and _DetailIsPointer(tt.detail) == 0 \
and _NullableDetail(_SymDetailAt(pool, a, asn.value)) == 0 \
and _ValueIsPointer(pool, a, asn.value) != 0:
_EmitTypeError(pool, doc, asn.value,
"a pointer/`own` value cannot be stored into a value-typed variable; declare the target as `own T` / `T*`, or dereference explicitly (`*p`)", errors)
_WalkChecksExprs(pool, doc, a, ws, table_pool, asn.value, errors)
if asn.value is not None and asn.value.kind() == ast.ASTKind.Name:
sn_a: ast.Name* = ast.Name*(asn.value)
@@ -1466,6 +1660,11 @@ def _WalkChecks(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*,
continue
if k == ast.ASTKind.Return:
rt: ast.Return* = ast.Return*(s)
if rt.value is not None and ret_ann is not None \
and _NullableDetail(ret_ann) == 0 \
and _NullableDetail(_SymDetailAt(pool, a, rt.value)) != 0:
_EmitTypeError(pool, doc, rt.value,
"the function's return type is not nullable: a nullable value may be None; return `T?` or unwrap it with `match` first", errors)
if rt.value is not None and ret_t is not None \
and ret_t.kind != LspTypes.LT_VOID and ret_t.kind != LspTypes.LT_NONE \
and ret_t.kind != LspTypes.LT_UNKNOWN: