From 7289b027ecf31de9bf5edd0a5d1eff238cfc6c00 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Tue, 3 Nov 2026 16:00:13 +0800 Subject: [PATCH] feat(lsp): unknown-name warnings, Nil validation and match exhaustiveness `Nil` is rejected outside nullable/`Option` matches in both the compiler and the language server, non-exhaustive nullable matches report the missing branch names, and unresolved plain names are published as warnings. Diagnostics now honour ParseError.severity. --- App/lib/core/Gen/MatchGenMixin.vp | 28 +++++- App/lib/lsp/LspChecks.vp | 136 +++++++++++++++++++++++++++++- App/lib/lsp/LspDiagnostics.vp | 4 +- App/lib/lsp/LspSemantic.vp | 2 - 4 files changed, 163 insertions(+), 7 deletions(-) diff --git a/App/lib/core/Gen/MatchGenMixin.vp b/App/lib/core/Gen/MatchGenMixin.vp index b2ea532..965091c 100644 --- a/App/lib/core/Gen/MatchGenMixin.vp +++ b/App/lib/core/Gen/MatchGenMixin.vp @@ -81,8 +81,10 @@ novirtual class MatchGenMixin: dpat: ast.AST* = ast.MatchCase*(dcase).pattern if dpat is not None: if dpat.kind() == ast.ASTKind.MatchAs and ast.MatchAs*(dpat).pattern is None: - # Count only guardless wildcard arms; guarded `case _ if ...` arms may repeat and are not counted as default. - if ast.MatchCase*(dcase).guard is None: default_cnt = default_cnt + 1 + # `case Nil` is an Option pattern, not a wildcard; count only guardless `case _`. + dname_chk: str = ast.MatchAs*(dpat).name + if dname_chk is None or string.strcmp(dname_chk, "Nil") != 0: + if ast.MatchCase*(dcase).guard is None: default_cnt = default_cnt + 1 elif m_node.is_switch == 0: dcj: int = dci + 1 while dcj < ccn: @@ -554,6 +556,8 @@ novirtual class MatchGenMixin: elif pk == ast.ASTKind.MatchAs: ma: ast.MatchAs* = ast.MatchAs*(pat) if ma.pattern is None: + if ma.name is not None and string.strcmp(ma.name, "Nil") == 0: + self.Gen.TypeGenErr.fatal_error(pat, "`Nil` is only valid for a nullable Option match (`own T?` / `T?`), not a renum match", "TypeError") is_def = 1 has_default = 1 default_case_node = case_node @@ -1102,10 +1106,30 @@ novirtual class MatchGenMixin: gzero: mlirlite.Value* = mlirlite.const_int(pool, gbits if gbits > 0 else 32, 0) return mlirlite.build_icmp(builder, mlirlite.ICMP_NE, gv, gzero) + def _pattern_is_nil(self, pat: ast.AST*) -> int: + """1 when a pattern is spelled `Nil` / `Nil()`.""" + if pat is None: return 0 + if pat.kind() == ast.ASTKind.MatchAs: + ma: ast.MatchAs* = ast.MatchAs*(pat) + if ma.pattern is None and ma.name is not None and string.strcmp(ma.name, "Nil") == 0: return 1 + return 0 + if pat.kind() == ast.ASTKind.MatchClass: + vnm_n: str = self._extract_variant_name_from_cls(pat) + if vnm_n is not None and string.strcmp(vnm_n, "Nil") == 0: return 1 + return 0 + def _handle_simple_match(self, trans: HT.Translator*, node: ast.Match*, subject_val: mlirlite.Value*) -> int: pool: memhub.MemBuddy* = trans.Pool builder: mlirlite.IRBuilder* = trans._cur_builder func: mlirlite.Function* = trans._cur_func + # `Nil` is an Option pattern, never a wildcard/capture. + if node.cases is not None: + ni: int = 0 + while ni < int(len(node.cases)): + nc: ast.MatchCase* = ast.MatchCase*(node.cases.get(sizet(ni))) + ni += 1 + if nc is not None and self._pattern_is_nil(nc.pattern) != 0: + self.Gen.TypeGenErr.fatal_error(nc.pattern, "`Nil` is only valid for a nullable Option match (`own T?` / `T?`) or the builtin Option renum; it is not a wildcard", "TypeError") # Same as the main path: locally track this simple match's br end_bb and overwrite _ctrl_merge_reachable at the end. sm_merge_reach: int = 0 diff --git a/App/lib/lsp/LspChecks.vp b/App/lib/lsp/LspChecks.vp index 6ad9090..a8fc36f 100644 --- a/App/lib/lsp/LspChecks.vp +++ b/App/lib/lsp/LspChecks.vp @@ -608,7 +608,8 @@ def _ExprLen(doc: LspDocuments.LspDocument*, node: ast.AST*) -> int: def _EmitTypeError(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*, - node: ast.AST*, msg: str, errors: list[ast.ParseError*]*): + node: ast.AST*, msg: str, errors: list[ast.ParseError*]*, + severity: int = 1): if node is None or msg is None or errors is None: return if int(len(errors)) >= LSP_CHECK_DIAG_CAP: @@ -638,6 +639,7 @@ def _EmitTypeError(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*, e.col = sc e.end_line = line0 + 1 e.end_col = ec + e.severity = severity errors.append(e) @@ -996,6 +998,97 @@ def _NullableDetail(det: str) -> int: return 0 +def _OccAtExpr(a: LspSymbols.LspAnalysis*, expr: ast.AST*) -> LspSymbols.LspOccurrence*: + """Occurrence at a Name / Attribute expression's identifier position.""" + if a is None or expr is None: return None + line0: int = 0 + col: int = 0 + name: str = None + if expr.kind() == ast.ASTKind.Name: + nm: ast.Name* = (ast.Name*)(expr) + if nm.id is None: return None + line0 = nm.lineno - 1 + col = nm.col_offset + name = nm.id + elif expr.kind() == ast.ASTKind.Attribute: + at: ast.Attribute* = (ast.Attribute*)(expr) + if at.attr is None: return None + line0 = at.lineno - 1 + col = at.col_offset + name = at.attr + else: + return None + colu: int = col + if a.doc is not None: colu = LspSource.ByteColToUtf16(a.doc, line0, col) + if a.occs is not None: + i: int = 0 + while i < int(len(a.occs)): + o: LspSymbols.LspOccurrence* = a.occs.get(sizet(i)) + i += 1 + if o is not None and o.line == line0 and o.sc == colu and o.name is not None: + if string.strcmp(o.name, name) == 0: return o + return None + + +def _IsKnownPlainName(name: str) -> int: + if name is None: return 0 + if string.strcmp(name, "self") == 0 or string.strcmp(name, "cls") == 0: return 1 + if string.strcmp(name, "None") == 0 or string.strcmp(name, "True") == 0 or string.strcmp(name, "False") == 0: return 1 + if string.strcmp(name, "Some") == 0 or string.strcmp(name, "Nil") == 0: return 1 + if LspSymbols._IsBuiltinTypeName(name) != 0: return 1 + if string.strcmp(name, "print") == 0 or string.strcmp(name, "len") == 0 or string.strcmp(name, "range") == 0: return 1 + if string.strcmp(name, "malloc") == 0 or string.strcmp(name, "free") == 0 or string.strcmp(name, "sizeof") == 0: return 1 + if string.strcmp(name, "memcpy") == 0 or string.strcmp(name, "memset") == 0: return 1 + if string.strcmp(name, "exit") == 0 or string.strcmp(name, "abort") == 0: return 1 + if string.strcmp(name, "min") == 0 or string.strcmp(name, "max") == 0 or string.strcmp(name, "abs") == 0: return 1 + if string.strcmp(name, "ord") == 0 or string.strcmp(name, "chr") == 0: return 1 + return 0 + + +def _LspNilPattern(pat: ast.AST*) -> int: + """1 when a pattern is spelled `Nil` / `Nil()` (not the MatchSingleton None).""" + if pat is None: return 0 + k: int = pat.kind() + if k == ast.ASTKind.MatchAs: + ma: ast.MatchAs* = (ast.MatchAs*)(pat) + if ma.pattern is None and ma.name is not None and string.strcmp(ma.name, "Nil") == 0: return 1 + return 0 + if k == ast.ASTKind.MatchClass: + cls: ast.AST* = (ast.MatchClass*)(pat).cls + if cls is not None and cls.kind() == ast.ASTKind.Attribute: + at: ast.Attribute* = (ast.Attribute*)(cls) + if at.attr is not None and string.strcmp(at.attr, "Nil") == 0: return 1 + if cls is not None and cls.kind() == ast.ASTKind.Name: + nm: ast.Name* = (ast.Name*)(cls) + if nm.id is not None and string.strcmp(nm.id, "Nil") == 0: return 1 + return 0 + + +def _LspOptPatternKind(pat: ast.AST*) -> int: + """1=Some, 2=Nil/None, 3=wildcard/capture, 0=other.""" + if pat is None: return 0 + k: int = pat.kind() + if k == ast.ASTKind.MatchSingleton: return 2 + if k == ast.ASTKind.MatchAs: + ma: ast.MatchAs* = (ast.MatchAs*)(pat) + if ma.pattern is None: + if ma.name is None: return 3 + if string.strcmp(ma.name, "Nil") == 0: return 2 + return 3 + return 0 + if k == ast.ASTKind.MatchClass: + cls: ast.AST* = (ast.MatchClass*)(pat).cls + vnm: str = None + if cls is not None and cls.kind() == ast.ASTKind.Attribute: + vnm = (ast.Attribute*)(cls).attr + elif cls is not None and cls.kind() == ast.ASTKind.Name: + vnm = (ast.Name*)(cls).id + if vnm is not None: + if string.strcmp(vnm, "Some") == 0: return 1 + if string.strcmp(vnm, "Nil") == 0: return 2 + return 0 + + def _SymDetailAt(pool: memhub.MemManager*, a: LspSymbols.LspAnalysis*, expr: ast.AST*) -> str: """Resolved symbol detail (annotation text) at a Name/Attribute expression.""" @@ -1046,6 +1139,10 @@ def _WalkChecksExprs(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*, nm_m: ast.Name* = ast.Name*(e) if nm_m.id is not None and _MovedIs(nm_m.id) != 0: _EmitTypeError(pool, doc, e, f"cannot use '{nm_m.id}': its value was moved (ownership transferred to another binding)", errors) + elif nm_m.id is not None and _IsKnownPlainName(nm_m.id) == 0: + occ_u: LspSymbols.LspOccurrence* = _OccAtExpr(a, e) + 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.Call: call: ast.Call* = ast.Call*(e) @@ -1400,7 +1497,42 @@ def _WalkChecks(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*, _WalkChecks(pool, doc, a, ws, table_pool, s.children, errors) continue if k == ast.ASTKind.Match: - _WalkChecks(pool, doc, a, ws, table_pool, s.children, errors) + mt: ast.Match* = ast.Match*(s) + _WalkChecksExprs(pool, doc, a, ws, table_pool, mt.subject, errors) + det_s: str = _SymDetailAt(pool, a, mt.subject) + nullable_s: int = _NullableDetail(det_s) + opt_renum: int = 0 + if det_s is not None and string.strstr(det_s, "Option[") is not None: opt_renum = 1 + has_some: int = 0 + has_nil: int = 0 + has_wild: int = 0 + if mt.cases is not None: + ci: int = 0 + while ci < int(len(mt.cases)): + ck: ast.MatchCase* = (ast.MatchCase*)(mt.cases.get(sizet(ci))) + ci += 1 + if ck is None: continue + pk_o: int = _LspOptPatternKind(ck.pattern) + if pk_o == 1: has_some = 1 + elif pk_o == 2: has_nil = 1 + elif pk_o == 3: has_wild = 1 + elif _LspNilPattern(ck.pattern) != 0: has_nil = 1 + if nullable_s == 0 and opt_renum == 0 and _LspNilPattern(ck.pattern) != 0: + _EmitTypeError(pool, doc, ck.pattern, + "`Nil` is only valid for a nullable Option match (`own T?` / `T?`) or the builtin Option renum", errors) + _WalkChecks(pool, doc, a, ws, table_pool, ck.children, errors) + if nullable_s != 0 or opt_renum != 0: + miss: str = "" + if has_some == 0: miss = "Some(...)" + if has_nil == 0 and has_wild == 0: + if string.strlen(miss) > 0: miss = f"{miss}, Nil" + else: miss = "Nil" + if string.strlen(miss) > 0: + msg_e: bytes = pool.alloc(sizet(320)) + if msg_e is not None: + stdio.snprintf(msg_e, sizet(320), + "match is not exhaustive: missing branch(es): %s (add the missing `case` or `case _`)", miss) + _EmitTypeError(pool, doc, mt, str(msg_e), errors) continue diff --git a/App/lib/lsp/LspDiagnostics.vp b/App/lib/lsp/LspDiagnostics.vp index df546ff..43fa08a 100644 --- a/App/lib/lsp/LspDiagnostics.vp +++ b/App/lib/lsp/LspDiagnostics.vp @@ -65,7 +65,9 @@ def _AddDiagnostic(pool: memhub.MemManager*, arr: json.JsonValue*, if diag is None: return json.object_set(pool, diag, "range", rng) - json.object_set(pool, diag, "severity", json.int_val(pool, 1)) + sev: int = e.severity + if sev <= 0: sev = 1 + json.object_set(pool, diag, "severity", json.int_val(pool, int64(sev))) json.object_set(pool, diag, "source", json.string_val(pool, "viperc")) msg: str = e.message if msg is None: diff --git a/App/lib/lsp/LspSemantic.vp b/App/lib/lsp/LspSemantic.vp index eb5f157..abaf45f 100644 --- a/App/lib/lsp/LspSemantic.vp +++ b/App/lib/lsp/LspSemantic.vp @@ -539,8 +539,6 @@ def _LexStream(pool: memhub.MemManager*, typ = ST_KEYWORD elif tok.type == ast.TokenType.Name and tok.str_val is not None and string.strcmp(tok.str_val, "Some") == 0: typ = ST_CLASS - elif tok.type == ast.TokenType.Name and tok.str_val is not None and string.strcmp(tok.str_val, "Nil") == 0: - typ = ST_PROPERTY elif tok.type == ast.TokenType.Name and _IsBuiltinFunctionName(tok.str_val) != 0: typ = ST_FUNCTION end_line0: int = line0