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
This commit is contained in:
Christoph Brandau
2026-09-03 08:52:03 +02:00
parent 53ebc5d055
commit 41d117fe2c
5 changed files with 435 additions and 8 deletions
+44
View File
@@ -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,
)
)
+252 -4
View File
@@ -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))
+136 -4
View File
@@ -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