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:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user