From 72f894b05da39ef0495d44a0b4c4b11eb2ce4a24 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 18:40:06 +0200 Subject: [PATCH] add goto --- server/src/lsp_server.py | 72 +++++++++++ .../python_tests/test_goto_definition.py | 116 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 server/tests/python_tests/test_goto_definition.py diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 199462b..09b3286 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -444,6 +444,77 @@ def hover(params: lsp.HoverParams) -> lsp.Hover: return None +@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION) +def goto_definition(params: lsp.DefinitionParams): + """Provide go-to-definition locations for Tcl procs. + + Strategy: + - Find the token under the cursor. + - If it matches a custom proc collected in proc_signatures, locate its declaration + by searching the current document first, then other indexed files. + - Return a Location pointing to the proc name in its declaration line. + """ + doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + pos = params.position + try: + line = doc.lines[pos.line] + except IndexError: + return None + + # Identify token under cursor + token = None + for m in re.finditer(r"\b\w+\b", line): + if m.start() <= pos.character <= m.end(): + token = m.group(0) + break + if not token: + return None + + # Helper to search a single source text for a proc declaration + def find_decl_in_source(source_text: str, uri: str) -> Optional[lsp.Location]: + lines = source_text.split("\n") + pattern = re.compile(r"^\s*proc\s+" + re.escape(token) + r"\b") + for i, ln in enumerate(lines): + m = pattern.match(ln) + if m: + start_char = ln.find(token) + if start_char < 0: + start_char = max(m.end() - len(token), 0) + start = lsp.Position(i, start_char) + end = lsp.Position(i, start_char + len(token)) + return lsp.Location(uri=uri, range=lsp.Range(start=start, end=end)) + return None + + # 1) Search in current document + loc = find_decl_in_source(doc.source, doc.uri) + if loc: + return loc + + # 2) Search in indexed files from proc_signatures + # Build list of candidate files that declare this token as a proc + candidate_files: List[str] = [] + for file_path, procs in LSP_SERVER.proc_signatures.items(): + if token in procs: + candidate_files.append(file_path) + + for fp in candidate_files: + uri = pathlib.Path(fp).as_uri() + # Try to get from workspace if available; else read from disk + try: + other_doc = LSP_SERVER.workspace.get_text_document(uri) + source = other_doc.source + except Exception: + try: + source = pathlib.Path(fp).read_text(encoding="utf-8") + except Exception: + continue + loc = find_decl_in_source(source, uri) + if loc: + return loc + + return None + + # ********************************************************** # Linting features end here # ********************************************************** @@ -508,6 +579,7 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult: capabilities=lsp.ServerCapabilities( document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True), semantic_tokens_provider=lsp.SemanticTokensOptions(legend=semantic_tokens_legend, full=True, range=False), + definition_provider=True, ) ) diff --git a/server/tests/python_tests/test_goto_definition.py b/server/tests/python_tests/test_goto_definition.py new file mode 100644 index 0000000..09cd93e --- /dev/null +++ b/server/tests/python_tests/test_goto_definition.py @@ -0,0 +1,116 @@ +import sys +from pathlib import Path + +# Ensure server/src is on the path for imports +THIS_DIR = Path(__file__).parent +SRC_DIR = THIS_DIR.parent.parent / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +import lsprotocol.types as lsp # type: ignore +from lsp_server import LSP_SERVER, goto_definition # type: ignore + + +def _loc_to_tuple(loc: lsp.Location) -> tuple[str, int, int, int, int]: + """Helper to normalize Location into a tuple for easy asserts.""" + return ( + loc.uri, + loc.range.start.line, + loc.range.start.character, + loc.range.end.line, + loc.range.end.character, + ) + + +def _extract_first_location(result) -> lsp.Location | None: + if result is None: + return None + if isinstance(result, list): + return result[0] if result else None + return result + + +def test_goto_definition_same_file(tmp_path: Path): + source_lines = [ + "proc add {a b} {", + " return [expr {$a + $b}]", + "}", + "", + "set x [add 1 2]", + ] + source = "\n".join(source_lines) + + uri = Path(tmp_path / "same.tcl").as_uri() + # Put a text document into the workspace + LSP_SERVER.workspace.put_text_document( + lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source) + ) + + # Position on the word 'add' in the last line + line_idx = 4 + char_idx = source_lines[line_idx].index("add") + 1 # somewhere inside token + + params = lsp.DefinitionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=line_idx, character=char_idx), + ) + + result = goto_definition(params) + loc = _extract_first_location(result) + + assert loc is not None + assert loc.uri == uri + # Definition should be on line 0 at the token 'add' + start = loc.range.start + end = loc.range.end + assert start.line == 0 + assert end.line == 0 + assert source_lines[0][start.character : end.character] == "add" + + +def test_goto_definition_cross_file(tmp_path: Path): + # File A declares the proc + a_lines = [ + "proc myproc {arg} {", + " return $arg", + "}", + ] + a_src = "\n".join(a_lines) + a_path = tmp_path / "a.tcl" + a_uri = a_path.as_uri() + LSP_SERVER.workspace.put_text_document( + lsp.TextDocumentItem(uri=a_uri, language_id="tcl", version=1, text=a_src) + ) + + # Update indices for file A so proc_signatures gets populated + doc_a = LSP_SERVER.workspace.get_text_document(a_uri) + LSP_SERVER.update_poco_completion_for_file(doc_a) + + # File B calls the proc + b_lines = [ + "set y [myproc 42]", + ] + b_src = "\n".join(b_lines) + b_uri = (tmp_path / "b.tcl").as_uri() + LSP_SERVER.workspace.put_text_document( + lsp.TextDocumentItem(uri=b_uri, language_id="tcl", version=1, text=b_src) + ) + + call_line = 0 + call_char = b_lines[0].index("myproc") + 2 + + params = lsp.DefinitionParams( + text_document=lsp.TextDocumentIdentifier(uri=b_uri), + position=lsp.Position(line=call_line, character=call_char), + ) + + result = goto_definition(params) + loc = _extract_first_location(result) + + assert loc is not None + assert loc.uri == a_uri + start = loc.range.start + end = loc.range.end + assert start.line == 0 + assert a_lines[start.line][start.character : end.character] == "myproc" +