From 41d117fe2c28bb03a7b0246ec75a350aac6d01b7 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 3 Sep 2026 08:52:03 +0200 Subject: [PATCH] feat(lsp): add call hierarchy support for TCL and MOM Adds LSP call hierarchy support for TCL procedures and MOM events. A symbol index and identity logic underpin incoming and outgoing calls. LSP call hierarchy features are wired and changelog/README updated. - Implement data encoding for call hierarchy items and identity restoration - Wire LSP server to expose prepare_call_hierarchy, incoming_calls, and outgoing_calls - Add tests for cross-file calls and edge cases --- CHANGELOG.md | 1 + README.md | 2 + server/src/lsp_server.py | 44 ++++ server/src/tools/navigation.py | 256 ++++++++++++++++++- server/tests/python_tests/test_navigation.py | 140 +++++++++- 5 files changed, 435 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54d7c9c..5f9bf93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Unreleased +- Add incoming and outgoing call hierarchy for custom TCL procedures and MOM event handlers - Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support - Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files - Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops diff --git a/README.md b/README.md index e231c83..be68a47 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A comprehensive VS Code extension providing language support and remote debuggin - **Intelligent Code Analysis** - Linting and error detection for postprocessor code - **Auto-completion** - Context-aware code completion for faster development - **Signature Help** - Shows parameters and documentation for custom and NX procedures +- **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers - **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process ## Supported File Types @@ -96,6 +97,7 @@ Simply open any supported file type and enjoy: - Code formatting (Format Document command) - Hover information - Signature help while entering procedure arguments +- Incoming and outgoing call hierarchy for custom procedures and MOM event handlers - Remote NX Tcl debugging with breakpoints and full stepping ## Contributing diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index a58c49d..5179ead 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -51,8 +51,12 @@ from tools.inlay_hint import ( ) from tools.navigation import ( SymbolIdentity, + call_hierarchy_identity, + call_hierarchy_items, definition_identities, + incoming_call_hierarchy, matching_occurrences, + outgoing_call_hierarchy, symbol_at_position, workspace_symbols, ) @@ -629,6 +633,45 @@ def workspace_symbol(params: lsp.WorkspaceSymbolParams): return workspace_symbols(LSP_SERVER.navigation_snapshot(), params.query) +@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_PREPARE_CALL_HIERARCHY) +def prepare_call_hierarchy(params: lsp.CallHierarchyPrepareParams): + context = _navigation_context(params.text_document.uri, params.position) + if context is None: + return None + + indexes, _, _, identity = context + items = call_hierarchy_items(identity, indexes) + return items or None + + +@LSP_SERVER.feature(lsp.CALL_HIERARCHY_INCOMING_CALLS) +def incoming_calls(params: lsp.CallHierarchyIncomingCallsParams): + identity = call_hierarchy_identity(params.item) + if identity is None: + return [] + + indexes = LSP_SERVER.navigation_snapshot() + return incoming_call_hierarchy( + identity, + indexes, + definition_identities(indexes), + ) + + +@LSP_SERVER.feature(lsp.CALL_HIERARCHY_OUTGOING_CALLS) +def outgoing_calls(params: lsp.CallHierarchyOutgoingCallsParams): + identity = call_hierarchy_identity(params.item) + if identity is None: + return [] + + indexes = LSP_SERVER.navigation_snapshot() + return outgoing_call_hierarchy( + identity, + indexes, + definition_identities(indexes), + ) + + # ********************************************************** # Linting features end here # ********************************************************** @@ -704,6 +747,7 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult: references_provider=True, rename_provider=lsp.RenameOptions(prepare_provider=True), workspace_symbol_provider=True, + call_hierarchy_provider=True, ) ) diff --git a/server/src/tools/navigation.py b/server/src/tools/navigation.py index 9b77c49..8c07148 100644 --- a/server/src/tools/navigation.py +++ b/server/src/tools/navigation.py @@ -1,10 +1,10 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path import lsprotocol.types as lsp -from tclint.syntax_tree import BareWord, Command, List, Node, Script, VarSub - +from tclint.syntax_tree import Command, List, Node, Script, VarSub ROOT_NAMESPACE = "::" @@ -25,6 +25,8 @@ class SymbolOccurrence: symbol_kind: lsp.SymbolKind = lsp.SymbolKind.Variable container_name: str | None = None fallback_identity: SymbolIdentity | None = None + caller: SymbolIdentity | None = None + declaration_range: lsp.Range | None = None @dataclass(frozen=True) @@ -32,6 +34,7 @@ class FileSymbolIndex: path: str uri: str occurrences: tuple[SymbolOccurrence, ...] + document_range: lsp.Range | None = None @dataclass(frozen=True) @@ -105,6 +108,18 @@ def _name_range(node: Node, raw_name: str, *, variable_sub: bool = False) -> lsp ) +def _node_range(node: Node) -> lsp.Range | None: + if node.pos is None or node.end_pos is None: + return None + return lsp.Range( + start=lsp.Position(line=node.pos[0] - 1, character=node.pos[1] - 1), + end=lsp.Position( + line=node.end_pos[0] - 1, + character=node.end_pos[1] - 1, + ), + ) + + def _proc_identity(raw_name: str, namespace: str) -> SymbolIdentity: return SymbolIdentity(kind="proc", name=_qualify(raw_name, namespace)) @@ -251,8 +266,16 @@ def build_file_symbol_index( scope: _Scope, *, is_definition: bool, + declaration_range: lsp.Range | None = None, ) -> None: identity = _proc_identity(raw_name, scope.namespace) + caller = None + if not is_definition: + caller = ( + SymbolIdentity(kind="proc", name=scope.proc_name) + if scope.proc_name is not None + else SymbolIdentity(kind="file", name=filepath) + ) occurrences.append( SymbolOccurrence( identity=identity, @@ -266,6 +289,8 @@ def build_file_symbol_index( is_definition=is_definition, symbol_kind=lsp.SymbolKind.Function, container_name=_container_name(identity), + caller=caller, + declaration_range=declaration_range, ) ) @@ -319,7 +344,13 @@ def build_file_symbol_index( if raw_name is None or not isinstance(body, Script): return - add_proc(command.args[0], raw_name, scope, is_definition=True) + add_proc( + command.args[0], + raw_name, + scope, + is_definition=True, + declaration_range=_node_range(command), + ) proc_identity = _proc_identity(raw_name, scope.namespace) proc_namespace = _namespace_of(proc_identity.name) global_variables, namespace_variables = _scan_proc_imports( @@ -435,7 +466,12 @@ def build_file_symbol_index( walk_embedded(child, scope) walk_script(tree, _Scope(filepath=filepath)) - return FileSymbolIndex(path=filepath, uri=uri, occurrences=tuple(occurrences)) + return FileSymbolIndex( + path=filepath, + uri=uri, + occurrences=tuple(occurrences), + document_range=_node_range(tree), + ) def definition_identities(indexes: dict[str, FileSymbolIndex]) -> set[SymbolIdentity]: @@ -517,3 +553,215 @@ def workspace_symbols( ) ) return sorted(results, key=lambda symbol: symbol.name.casefold()) + + +_CALL_HIERARCHY_DATA_KIND = "nx-post-support.call-hierarchy" + + +def _call_hierarchy_data(identity: SymbolIdentity) -> dict[str, str]: + return { + "source": _CALL_HIERARCHY_DATA_KIND, + "kind": identity.kind, + "name": identity.name, + } + + +def call_hierarchy_identity(item: lsp.CallHierarchyItem) -> SymbolIdentity | None: + """Restore the symbol identity carried by a call hierarchy item.""" + data = item.data + if ( + not isinstance(data, dict) + or data.get("source") != _CALL_HIERARCHY_DATA_KIND + ): + return None + + kind = data.get("kind") + name = data.get("name") + if kind not in {"proc", "file"} or not isinstance(name, str): + return None + return SymbolIdentity(kind=kind, name=name) + + +def _proc_definitions( + indexes: dict[str, FileSymbolIndex], +) -> dict[SymbolIdentity, list[tuple[FileSymbolIndex, SymbolOccurrence]]]: + definitions: dict[ + SymbolIdentity, list[tuple[FileSymbolIndex, SymbolOccurrence]] + ] = {} + for index in indexes.values(): + for occurrence in index.occurrences: + if occurrence.is_definition and occurrence.identity.kind == "proc": + definitions.setdefault(occurrence.identity, []).append( + (index, occurrence) + ) + return definitions + + +def _unique_proc_definition( + identity: SymbolIdentity, + proc_definitions: dict[ + SymbolIdentity, list[tuple[FileSymbolIndex, SymbolOccurrence]] + ], +) -> tuple[FileSymbolIndex, SymbolOccurrence] | None: + matches = proc_definitions.get(identity, []) + if len(matches) != 1: + return None + return matches[0] + + +def _file_item( + identity: SymbolIdentity, + indexes: dict[str, FileSymbolIndex], +) -> lsp.CallHierarchyItem | None: + if identity.kind != "file": + return None + index = indexes.get(identity.name) + if index is None: + return None + + range_ = index.document_range or lsp.Range( + start=lsp.Position(line=0, character=0), + end=lsp.Position(line=0, character=0), + ) + selection_range = lsp.Range(start=range_.start, end=range_.start) + return lsp.CallHierarchyItem( + name=Path(index.path).name, + kind=lsp.SymbolKind.File, + uri=index.uri, + range=range_, + selection_range=selection_range, + detail=str(Path(index.path).parent), + data=_call_hierarchy_data(identity), + ) + + +def _call_hierarchy_item( + identity: SymbolIdentity, + indexes: dict[str, FileSymbolIndex], + proc_definitions: dict[ + SymbolIdentity, list[tuple[FileSymbolIndex, SymbolOccurrence]] + ], +) -> lsp.CallHierarchyItem | None: + if identity.kind == "file": + return _file_item(identity, indexes) + + definition = _unique_proc_definition(identity, proc_definitions) + if definition is None: + return None + + index, occurrence = definition + basename = _basename(identity.name) + symbol_kind = ( + lsp.SymbolKind.Event + if basename.startswith("MOM_") + else lsp.SymbolKind.Function + ) + return lsp.CallHierarchyItem( + name=_display_name(identity), + kind=symbol_kind, + uri=index.uri, + range=occurrence.declaration_range or occurrence.range, + selection_range=occurrence.range, + detail=Path(index.path).name, + data=_call_hierarchy_data(identity), + ) + + +def call_hierarchy_items( + identity: SymbolIdentity, + indexes: dict[str, FileSymbolIndex], +) -> list[lsp.CallHierarchyItem]: + """Build the hierarchy item for one unambiguous workspace procedure.""" + item = _call_hierarchy_item(identity, indexes, _proc_definitions(indexes)) + return [item] if item is not None else [] + + +def _range_key(range_: lsp.Range) -> tuple[int, int, int, int]: + return ( + range_.start.line, + range_.start.character, + range_.end.line, + range_.end.character, + ) + + +def _item_key(item: lsp.CallHierarchyItem) -> tuple[str, str, int, int]: + return ( + item.name.casefold(), + item.uri, + item.selection_range.start.line, + item.selection_range.start.character, + ) + + +def incoming_call_hierarchy( + identity: SymbolIdentity, + indexes: dict[str, FileSymbolIndex], + definitions: set[SymbolIdentity], +) -> list[lsp.CallHierarchyIncomingCall]: + """Return statically resolved workspace procedures that call ``identity``.""" + proc_definitions = _proc_definitions(indexes) + if _unique_proc_definition(identity, proc_definitions) is None: + return [] + + grouped: dict[SymbolIdentity, list[lsp.Range]] = {} + for _, occurrence in matching_occurrences(identity, indexes, definitions): + if occurrence.is_definition or occurrence.caller is None: + continue + caller = occurrence.caller + if caller.kind == "proc" and ( + _unique_proc_definition(caller, proc_definitions) is None + ): + continue + grouped.setdefault(caller, []).append(occurrence.range) + + results = [] + for caller, ranges in grouped.items(): + item = _call_hierarchy_item(caller, indexes, proc_definitions) + if item is not None: + results.append( + lsp.CallHierarchyIncomingCall( + from_=item, + from_ranges=sorted(ranges, key=_range_key), + ) + ) + return sorted(results, key=lambda call: _item_key(call.from_)) + + +def outgoing_call_hierarchy( + identity: SymbolIdentity, + indexes: dict[str, FileSymbolIndex], + definitions: set[SymbolIdentity], +) -> list[lsp.CallHierarchyOutgoingCall]: + """Return statically resolved workspace procedures called by ``identity``.""" + proc_definitions = _proc_definitions(indexes) + if identity.kind == "proc": + if _unique_proc_definition(identity, proc_definitions) is None: + return [] + elif identity.kind == "file": + if identity.name not in indexes: + return [] + else: + return [] + + grouped: dict[SymbolIdentity, list[lsp.Range]] = {} + for index in indexes.values(): + for occurrence in index.occurrences: + if occurrence.is_definition or occurrence.caller != identity: + continue + callee = resolve_identity(occurrence, definitions) + if _unique_proc_definition(callee, proc_definitions) is None: + continue + grouped.setdefault(callee, []).append(occurrence.range) + + results = [] + for callee, ranges in grouped.items(): + item = _call_hierarchy_item(callee, indexes, proc_definitions) + if item is not None: + results.append( + lsp.CallHierarchyOutgoingCall( + to=item, + from_ranges=sorted(ranges, key=_range_key), + ) + ) + return sorted(results, key=lambda call: _item_key(call.to)) diff --git a/server/tests/python_tests/test_navigation.py b/server/tests/python_tests/test_navigation.py index a5f7c0b..e696c85 100644 --- a/server/tests/python_tests/test_navigation.py +++ b/server/tests/python_tests/test_navigation.py @@ -1,22 +1,25 @@ import sys from pathlib import Path - 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 pygls.workspace.text_document import TextDocument - import lsp_server +import lsprotocol.types as lsp # type: ignore from lsp_tclserver import TclLanguageServer +from lsprotocol.converters import get_converter +from pygls.workspace.text_document import TextDocument from tools.navigation import ( SymbolIdentity, build_file_symbol_index, + call_hierarchy_identity, + call_hierarchy_items, definition_identities, + incoming_call_hierarchy, matching_occurrences, + outgoing_call_hierarchy, symbol_at_position, workspace_symbols, ) @@ -269,3 +272,132 @@ def test_duplicate_proc_definition_cannot_be_renamed(tmp_path: Path, monkeypatch ) assert result is None + + +def test_call_hierarchy_tracks_cross_file_event_calls(tmp_path: Path, monkeypatch): + library_source = """proc leaf {} { return } +namespace eval shop { + proc middle {} { + ::leaf + } +} +""" + event_source = """proc MOM_linear_move {} { + ::shop::middle + ::shop::middle + puts done +} +""" + bootstrap_source = "::shop::middle\n" + library = _document(tmp_path / "library.tcl", library_source) + event = _document(tmp_path / "event.tcl", event_source) + bootstrap = _document(tmp_path / "bootstrap.tcl", bootstrap_source) + server = TclLanguageServer( + name="call-hierarchy-test", version="1", max_workers=1 + ) + assert server.update_poco_completion_for_file(library) + assert server.update_poco_completion_for_file(event) + assert server.update_poco_completion_for_file(bootstrap) + monkeypatch.setattr(lsp_server, "LSP_SERVER", server) + + prepared = lsp_server.prepare_call_hierarchy( + lsp.CallHierarchyPrepareParams( + text_document=lsp.TextDocumentIdentifier(uri=event.uri), + position=_position(event_source, "middle"), + ) + ) + + assert prepared is not None + assert len(prepared) == 1 + middle = prepared[0] + assert middle.name == "shop::middle" + assert middle.kind == lsp.SymbolKind.Function + assert middle.uri == library.uri + assert call_hierarchy_identity(middle) == SymbolIdentity( + kind="proc", name="::shop::middle" + ) + + incoming = lsp_server.incoming_calls( + lsp.CallHierarchyIncomingCallsParams(item=middle) + ) + assert len(incoming) == 2 + event_call = next( + call for call in incoming if call.from_.kind == lsp.SymbolKind.Event + ) + file_call = next( + call for call in incoming if call.from_.kind == lsp.SymbolKind.File + ) + assert event_call.from_.name == "MOM_linear_move" + assert len(event_call.from_ranges) == 2 + assert file_call.from_.name == "bootstrap.tcl" + assert len(file_call.from_ranges) == 1 + incoming_payload = get_converter().unstructure(event_call) + assert incoming_payload["from"]["name"] == "MOM_linear_move" + assert len(incoming_payload["fromRanges"]) == 2 + assert incoming_payload["from"]["data"]["source"] == ( + "nx-post-support.call-hierarchy" + ) + + outgoing = lsp_server.outgoing_calls( + lsp.CallHierarchyOutgoingCallsParams(item=middle) + ) + assert len(outgoing) == 1 + assert outgoing[0].to.name == "leaf" + assert _range_text(library_source, outgoing[0].from_ranges[0]) == "leaf" + + event_item = event_call.from_ + event_outgoing = lsp_server.outgoing_calls( + lsp.CallHierarchyOutgoingCallsParams(item=event_item) + ) + assert len(event_outgoing) == 1 + assert event_outgoing[0].to.name == "shop::middle" + assert len(event_outgoing[0].from_ranges) == 2 + + file_outgoing = lsp_server.outgoing_calls( + lsp.CallHierarchyOutgoingCallsParams(item=file_call.from_) + ) + assert len(file_outgoing) == 1 + assert file_outgoing[0].to.name == "shop::middle" + assert len(file_outgoing[0].from_ranges) == 1 + + +def test_call_hierarchy_ignores_dynamic_and_ambiguous_calls(tmp_path: Path): + caller_source = """proc caller {command} { + $command + duplicate +} +""" + caller = _index(tmp_path / "caller.tcl", caller_source) + duplicate_a = _index(tmp_path / "duplicate_a.tcl", "proc duplicate {} {}\n") + duplicate_b = _index(tmp_path / "duplicate_b.tcl", "proc duplicate {} {}\n") + indexes = { + caller.path: caller, + duplicate_a.path: duplicate_a, + duplicate_b.path: duplicate_b, + } + definitions = definition_identities(indexes) + caller_identity = SymbolIdentity(kind="proc", name="::caller") + duplicate_identity = SymbolIdentity(kind="proc", name="::duplicate") + + assert call_hierarchy_items(duplicate_identity, indexes) == [] + assert ( + incoming_call_hierarchy(duplicate_identity, indexes, definitions) == [] + ) + assert outgoing_call_hierarchy(caller_identity, indexes, definitions) == [] + + +def test_call_hierarchy_item_uses_whole_proc_range(tmp_path: Path): + source = """proc multiline {} { + return +} +""" + index = _index(tmp_path / "range.tcl", source) + items = call_hierarchy_items( + SymbolIdentity(kind="proc", name="::multiline"), + {index.path: index}, + ) + + assert len(items) == 1 + assert items[0].selection_range.start == lsp.Position(line=0, character=5) + assert items[0].range.start == lsp.Position(line=0, character=0) + assert items[0].range.end.line == 2