From b38d103c1710c91deb35be09b44f7260df2ea230 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Tue, 27 Oct 2026 12:55:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(lsp):=20=E7=82=B9=E5=8F=B7=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E9=80=90=E6=AE=B5=E9=AB=98=E4=BA=AE=E4=B8=8E=20projec?= =?UTF-8?q?t=20includes=20=E8=B7=AF=E5=BE=84=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit import a.b.c / from a.b import X 的每个模块段都有 namespace token 并可跳转到对应模块文件;预绑定出现点不再被重解析覆盖;project source/includes 规范化为无 .. 的路径,避免索引路径失配后按需反复解析导致崩溃。 --- App/lib/lsp/LspIndex.vp | 28 +++++- App/lib/lsp/LspSymbols.vp | 88 +++++++++++++++++++ App/lib/lsp/LspWorkspace.vp | 13 +-- Tools/lsp/fixtures/gate10_pkg/__init__.vp | 1 + Tools/lsp/fixtures/gate10_pkg/sub/__init__.vp | 1 + Tools/lsp/fixtures/gate10_pkg/sub/mod.vp | 2 + Tools/lsp/fixtures/gate10_use.vp | 1 + Tools/lsp/probe.py | 58 ++++++++++++ 8 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 Tools/lsp/fixtures/gate10_pkg/__init__.vp create mode 100644 Tools/lsp/fixtures/gate10_pkg/sub/__init__.vp create mode 100644 Tools/lsp/fixtures/gate10_pkg/sub/mod.vp diff --git a/App/lib/lsp/LspIndex.vp b/App/lib/lsp/LspIndex.vp index 4108504..edca1e7 100644 --- a/App/lib/lsp/LspIndex.vp +++ b/App/lib/lsp/LspIndex.vp @@ -686,7 +686,9 @@ def ResolveInFile(pool: memhub.MemManager*, table_pool: memhub.MemManager*, s: LspSymbols.LspSymbol* = FindSymbol(path, container, name) if s is not None: return s - if _bulk_resolve != 0 or _resolve_only != 0: + if _bulk_resolve != 0: + return None + if _resolve_only != 0 and FileEntry(path) is not None: return None text: str = LspFiles.ReadSource(pool, path) if text is None: @@ -872,6 +874,14 @@ def _ChainFromModule(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*, hops: int) -> LspTarget*: segs: str = rest while segs is not None: + if cur_is_file != 0: + base: str = LspFiles.BaseName(pool, cur) + if base is not None and string.strcmp(base, "__init__.vp") == 0: + # A package's __init__.vp is not a symbol table for its submodules; descend from + # the package directory for the remaining dotted segments. + cur = LspFiles.DirName(pool, cur) + cur_is_file = 0 + continue h: str = None r: str = None LspSymbols._SplitHead(pool, segs, &h, &r) @@ -885,7 +895,7 @@ def _ChainFromModule(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*, if member.import_kind != LspSymbols.LSP_IMPORT_NONE: if member.import_kind == LspSymbols.LSP_IMPORT_FROM: return _ResolveFromTarget(ws, pool, table_pool, member.file, member, hops + 1) - mod_file: str = _ModulePath(ws, pool, member.import_mod) + mod_file: str = _ModuleFile(ws, pool, member.file, member) if mod_file is not None: return _FileTarget(pool, mod_file, member.import_mod) return None @@ -950,13 +960,23 @@ def ResolveSymbol(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*, if s.import_kind == LspSymbols.LSP_IMPORT_FROM: return _ResolveFromTarget(ws, pool, table_pool, from_file, s, hops) if s.import_kind == LspSymbols.LSP_IMPORT_MODULE: - mod_file: str = _ModulePath(ws, pool, s.import_mod) + mod_file: str = _ModuleFile(ws, pool, from_file, s) if mod_file is None: return None return _FileTarget(pool, mod_file, s.import_mod) return _SymbolTarget(pool, s) +def _ModuleFile(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*, + from_file: str, s: LspSymbols.LspSymbol*) -> str: + """Resolve a module import binding, honoring relative import levels.""" + if s is None: + return None + if s.import_level > 0: + return _ResolveFromFile(ws, pool, from_file, s) + return _ModulePath(ws, pool, s.import_mod) + + def ResolveWildcard(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*, table_pool: memhub.MemManager*, from_file: str, wildcard: LspSymbols.LspSymbol*, @@ -1043,7 +1063,7 @@ def ResolveChain(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*, segs = r return None if head.import_kind == LspSymbols.LSP_IMPORT_MODULE: - mod_file: str = _ModulePath(ws, pool, head.import_mod) + mod_file: str = _ModuleFile(ws, pool, from_file, head) if mod_file is None: return None if rest is None: diff --git a/App/lib/lsp/LspSymbols.vp b/App/lib/lsp/LspSymbols.vp index 9dc1b51..79ad604 100644 --- a/App/lib/lsp/LspSymbols.vp +++ b/App/lib/lsp/LspSymbols.vp @@ -251,6 +251,18 @@ def _CopyStr(pool: memhub.MemManager*, s: str, cap: int) -> str: return str(buf) +def _SliceStr(pool: memhub.MemManager*, s: str, start: int, end: int) -> str: + if s is None or end <= start: + return None + n: sizet = sizet(end - start) + buf: bytes = pool.alloc(n + 1) + if buf is None: + return None + string.memcpy(buf, s + start, n) + buf[n] = 0 + return str(buf) + + def _NameSpan(pool: memhub.MemManager*, doc: LspDocuments.LspDocument*, lineno: int, col: int, name: str) -> LspSource.LspRange*: if lineno <= 0 or name is None: @@ -764,6 +776,9 @@ def _ResolveOccurrences(a: LspAnalysis*): i += 1 if o is None or o.path is None: continue + # Import declarations and dotted module-path segments are pre-bound by the walker. + if o.sym is not None or o.head is not None: + continue h: str = None rest: str = None _SplitHead(a.pool, o.path, &h, &rest) @@ -894,6 +909,61 @@ def _DeclImportOcc(pool: memhub.MemManager*, a: LspAnalysis*, scope: LspScope*, o.sym = sym +def _NewHiddenModule(pool: memhub.MemManager*, a: LspAnalysis*, scope: LspScope*, + module: str, level: int) -> LspSymbol*: + """Module head used only for dotted-import navigation (not bound in any scope).""" + if a is None or module is None: + return None + s: LspSymbol* = _NewSymbol(pool, module, LSP_KIND_MODULE, None, scope) + if s is None: + return None + s.file = a.file + s.import_kind = LSP_IMPORT_MODULE + s.import_mod = module + s.import_level = level + return s + + +def _AddModulePathOccs(pool: memhub.MemManager*, a: LspAnalysis*, scope: LspScope*, + lineno: int, col: int, path: str, level: int, + skip_first: int, head: LspSymbol*) -> LspSymbol*: + """Add a namespace occurrence for every segment of a dotted module path. + + The first segment resolves through `head` (or a hidden module symbol), later segments are + attributes chained from it, so definition lands on the corresponding module file/directory. + """ + if a is None or scope is None or path is None or path[0] == 0 or lineno <= 0: + return head + plen: int = int(string.strlen(path)) + first_end: int = 0 + while first_end < plen and path[first_end] != '.': + first_end += 1 + if head is None: + first: str = _SliceStr(pool, path, 0, first_end) + head = _NewHiddenModule(pool, a, scope, first, level) + if head is None: + return None + start: int = 0 + while start < plen: + end: int = start + while end < plen and path[end] != '.': + end += 1 + if not (start == 0 and skip_first != 0): + name: str = _SliceStr(pool, path, start, end) + if name is not None: + o: LspOccurrence* = _AddOcc(pool, a, scope, name, lineno, col + start, + int(string.strlen(name)), ast.ASTCtx.Load, 0) + if o is not None: + if start == 0: + o.sym = head + o.head = head + else: + o.head = head + o.rest = _SliceStr(pool, path, first_end + 1, end) + start = end + 1 + return head + + def _JoinImportDetail(pool: memhub.MemManager*, a: str, b: str, c: str) -> str: if a is None or b is None: @@ -1191,10 +1261,28 @@ def _WalkStmt(pool: memhub.MemManager*, a: LspAnalysis*, scope: LspScope*, sym.import_kind = LSP_IMPORT_MODULE sym.import_mod = bound_mod _DeclImportOcc(pool, a, scope, al, bound, sym) + if al.asname is not None: + _AddModulePathOccs(pool, a, scope, al.lineno, al.col_offset, + al.name, 0, 0, None) + else: + _AddModulePathOccs(pool, a, scope, al.lineno, al.col_offset, + al.name, 0, 1, sym) i += 1 return if k == ast.ASTKind.ImportFrom: ifr: ast.ImportFrom* = (ast.ImportFrom*)(node) + if ifr.module is not None and ifr.lineno > 0: + mlen: int = int(string.strlen(ifr.module)) + first_end: int = 0 + while first_end < mlen and ifr.module[first_end] != '.': + first_end += 1 + first_seg: str = _SliceStr(pool, ifr.module, 0, first_end) + if first_seg is not None: + mcol: int = _FindWordInLine(a.doc, ifr.lineno - 1, + ifr.col_offset + 4, first_seg) + if mcol >= 0: + _AddModulePathOccs(pool, a, scope, ifr.lineno, mcol, + ifr.module, ifr.level, 0, None) if ifr.names is not None: i: int = 0 while i < int(len(ifr.names)): diff --git a/App/lib/lsp/LspWorkspace.vp b/App/lib/lsp/LspWorkspace.vp index f51e734..0ece618 100644 --- a/App/lib/lsp/LspWorkspace.vp +++ b/App/lib/lsp/LspWorkspace.vp @@ -15,10 +15,11 @@ def _AbsJoin(pool: memhub.MemManager*, base: str, path: str) -> str: if path is None or path[0] == 0: return path plen: sizet = string.strlen(path) - if path[0] == '/' or path[0] == '\\': - return LspPools.DupStr(pool, path) - if plen >= 2 and path[1] == ':': - return LspPools.DupStr(pool, path) + if path[0] == '/' or path[0] == '\\' or (plen >= 2 and path[1] == ':'): + norm_abs: str = os.path.normpath(path) + if norm_abs is None: + norm_abs = path + return LspPools.DupStr(pool, norm_abs) if base is None or base[0] == 0: return LspPools.DupStr(pool, path) joined: str = os.path.join(base, path) @@ -219,11 +220,11 @@ class LspWorkspace: if self.includes is None or self.includes[0] == 0: sibling: str = os.path.join(base, "includes") if os.isdir(sibling): - self.includes = sibling + self.includes = os.path.normpath(sibling) else: parent_sibling: str = os.path.join(base, "../includes") if os.isdir(parent_sibling): - self.includes = parent_sibling + self.includes = os.path.normpath(parent_sibling) else: self.includes = "" diff --git a/Tools/lsp/fixtures/gate10_pkg/__init__.vp b/Tools/lsp/fixtures/gate10_pkg/__init__.vp new file mode 100644 index 0000000..2d30372 --- /dev/null +++ b/Tools/lsp/fixtures/gate10_pkg/__init__.vp @@ -0,0 +1 @@ +"""Gate 10 dotted-import package.""" diff --git a/Tools/lsp/fixtures/gate10_pkg/sub/__init__.vp b/Tools/lsp/fixtures/gate10_pkg/sub/__init__.vp new file mode 100644 index 0000000..b5f34b1 --- /dev/null +++ b/Tools/lsp/fixtures/gate10_pkg/sub/__init__.vp @@ -0,0 +1 @@ +"""Gate 10 dotted-import subpackage.""" diff --git a/Tools/lsp/fixtures/gate10_pkg/sub/mod.vp b/Tools/lsp/fixtures/gate10_pkg/sub/mod.vp new file mode 100644 index 0000000..7facf78 --- /dev/null +++ b/Tools/lsp/fixtures/gate10_pkg/sub/mod.vp @@ -0,0 +1,2 @@ +def mod_fn(x: int) -> int: + return x diff --git a/Tools/lsp/fixtures/gate10_use.vp b/Tools/lsp/fixtures/gate10_use.vp index 6290eb4..19f70ae 100644 --- a/Tools/lsp/fixtures/gate10_use.vp +++ b/Tools/lsp/fixtures/gate10_use.vp @@ -4,6 +4,7 @@ from .gate10_types import Widget, DOUBLE from .gate10_stub import StubThing from .gate10_extra import * import gate10_types +import gate10_pkg.sub.mod def star_use(e: Extra) -> int: diff --git a/Tools/lsp/probe.py b/Tools/lsp/probe.py index fc8a4d4..c9e3d21 100644 --- a/Tools/lsp/probe.py +++ b/Tools/lsp/probe.py @@ -1080,6 +1080,41 @@ def gate7(exe: str) -> int: if not _find_token(sem_tokens, pool_pos["line"], pool_pos["character"], sem_types.index("class")): raise AssertionError(f"imported class was not highlighted: {pool_pos}") + + mem_pos = _position(app_text, "memhub.MemManager") + mem_pos["character"] += len("memhub.") + mem_def = client.request( + "textDocument/definition", {"textDocument": {"uri": app_uri}, "position": mem_pos}, + timeout=60.0, + ).get("result") + if not isinstance(mem_def, list) or not mem_def: + raise AssertionError(f"includes definition failed after adoption: {mem_def}") + if not mem_def[0]["uri"].endswith("memhub.vp"): + raise AssertionError(f"includes definition target is wrong: {mem_def}") + if not _find_token(sem_tokens, mem_pos["line"], mem_pos["character"], + sem_types.index("class")): + raise AssertionError(f"includes class was not highlighted: {mem_pos}") + + # A large include-root document must tokenize through the same project roots without + # exhausting the request pool via on-demand re-parsing. + parser_doc = root.parent / "includes" / "ast" / "parser.vp" + parser_text = parser_doc.read_text(encoding="utf-8") + parser_uri = parser_doc.as_uri() + client.notify( + "textDocument/didOpen", + {"textDocument": {"uri": parser_uri, "languageId": "viper", "version": 1, + "text": parser_text}}, + ) + client.wait_notification("textDocument/publishDiagnostics") + parser_semantic = client.request( + "textDocument/semanticTokens/full", {"textDocument": {"uri": parser_uri}}, + timeout=120.0, + ).get("result") + if not isinstance(parser_semantic, dict) or not parser_semantic.get("data"): + raise AssertionError(f"include-root semantic tokens failed: {parser_semantic}") + client.notify("textDocument/didClose", {"textDocument": {"uri": parser_uri}}) + client.wait_notification("textDocument/publishDiagnostics") + client.notify("textDocument/didClose", {"textDocument": {"uri": app_uri}}) client.wait_notification("textDocument/publishDiagnostics") @@ -1423,6 +1458,7 @@ def gate10(exe: str) -> int: names = ("gate10_types.vp", "gate10_stub.vpi", "gate10_extra.vp", "gate10_use.vp") for name in names: shutil.copy(fixtures / name, workspace / name) + shutil.copytree(fixtures / "gate10_pkg", workspace / "gate10_pkg") type_path = workspace / "gate10_types.vp" stub_path = workspace / "gate10_stub.vpi" use_path = workspace / "gate10_use.vp" @@ -1487,6 +1523,28 @@ def gate10(exe: str) -> int: if not star_def[0]["uri"].endswith("gate10_extra.vp"): raise AssertionError(f"wildcard definition target is wrong: {star_def}") + # Dotted module paths: every segment is a namespace and ctrl-click opens that module file. + dotted = _position(text, "gate10_pkg.sub.mod") + for delta, segment in ((0, "pkg"), (len("gate10_pkg."), "sub"), + (len("gate10_pkg.sub."), "mod")): + pos = {"line": dotted["line"], "character": dotted["character"] + delta} + if not _find_token(sem_tokens, pos["line"], pos["character"], + sem_types.index("namespace")): + raise AssertionError(f"dotted import segment is not highlighted: {segment} {pos}") + for delta, suffix in ((len("gate10_pkg."), "gate10_pkg/sub/__init__.vp"), + (len("gate10_pkg.sub."), "gate10_pkg/sub/mod.vp")): + pos = {"line": dotted["line"], "character": dotted["character"] + delta} + target = client.request( + "textDocument/definition", + {"textDocument": {"uri": uri}, "position": pos}, + timeout=120.0, + ).get("result") + if not isinstance(target, list) or not target: + raise AssertionError(f"dotted import definition failed: {pos} -> {target}") + got = target[0]["uri"].replace("\\", "/") + if not got.endswith(suffix): + raise AssertionError(f"dotted import definition target is wrong: {pos} -> {got}") + def at(needle: str, delta: int = 0, occurrence: int = 0) -> dict[str, int]: pos = _position(text, needle, occurrence) pos["character"] += delta