Files
nx_post_support/server/src/tools/navigation.py
T
Christoph Brandau d8611d7aea feat(tcl): add static variable name extraction and array key completion
This adds tooling to statically extract Tcl variable names from syntax
trees without evaluating substitutions, enabling better completions
for array elements and plain variables. The new helpers are wired
into the completion and navigation flows and are supported by tests
covering array keys and substitutions.

- Introduce variable_names.py with variable_name() and array_key_parts()
- Wire static name extraction into completion and symbol indexing
- Add tests for array key completion with substitutions
2026-09-10 09:07:18 +02:00

812 lines
26 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import lsprotocol.types as lsp
from tclint.syntax_tree import Command, List, Node, QuotedWord, Script, VarSub
from tools.variable_names import array_key_parts, variable_name
ROOT_NAMESPACE = "::"
@dataclass(frozen=True)
class SymbolIdentity:
kind: str
name: str
scope: str | None = None
@dataclass(frozen=True)
class SymbolOccurrence:
identity: SymbolIdentity
range: lsp.Range
placeholder: str
is_definition: bool = False
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
array_element: str | None = None
array_parts: tuple[str | None, ...] = ()
array_template_parts: tuple[str | None, ...] = ()
@dataclass(frozen=True)
class FileSymbolIndex:
path: str
uri: str
occurrences: tuple[SymbolOccurrence, ...]
document_range: lsp.Range | None = None
@dataclass(frozen=True)
class _Scope:
filepath: str
namespace: str = ROOT_NAMESPACE
proc_name: str | None = None
global_variables: tuple[tuple[str, str], ...] = ()
namespace_variables: tuple[tuple[str, str], ...] = ()
def _without_array_index(name: str) -> str:
return name.split("(", 1)[0]
def _basename(name: str) -> str:
return _without_array_index(name).rsplit("::", 1)[-1]
def _qualify(name: str, namespace: str) -> str:
name = _without_array_index(name)
if name.startswith("::"):
return name
if namespace == ROOT_NAMESPACE:
return f"::{name}"
return f"{namespace}::{name}"
def _namespace_of(qualified_name: str) -> str:
parent = qualified_name.rsplit("::", 1)[0]
return parent or ROOT_NAMESPACE
def _display_name(identity: SymbolIdentity) -> str:
if identity.kind == "variable" and identity.scope is not None:
return identity.name
return identity.name.removeprefix("::")
def _container_name(identity: SymbolIdentity) -> str | None:
if identity.scope is not None:
_, _, proc_name = identity.scope.partition("::proc::")
return proc_name.removeprefix("::") or None
qualified = identity.name.removeprefix("::")
if "::" not in qualified:
return None
return qualified.rsplit("::", 1)[0]
def _static_contents(node: Node | None) -> str | None:
value = getattr(node, "contents", None)
return value if isinstance(value, str) else None
def _name_range(node: Node, raw_name: str, *, variable_sub: bool = False) -> lsp.Range:
if variable_sub:
line, column = node.pos
column += 2 if getattr(node, "braced", False) else 1
else:
position = getattr(node, "contents_pos", None) or node.pos
if isinstance(node, QuotedWord) and node.contents is None and node.children:
position = node.children[0].pos
line, column = position
normalized = _without_array_index(raw_name)
prefix_length = normalized.rfind("::") + 2 if "::" in normalized else 0
start_character = column - 1 + prefix_length
name = normalized[prefix_length:]
return lsp.Range(
start=lsp.Position(line=line - 1, character=start_character),
end=lsp.Position(line=line - 1, character=start_character + len(name)),
)
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))
def _proc_fallback(raw_name: str, namespace: str) -> SymbolIdentity | None:
if raw_name.startswith("::") or "::" in raw_name or namespace == ROOT_NAMESPACE:
return None
return SymbolIdentity(kind="proc", name=_qualify(raw_name, ROOT_NAMESPACE))
def _variable_identity(raw_name: str, scope: _Scope) -> SymbolIdentity:
normalized = _without_array_index(raw_name)
if normalized.startswith("::"):
return SymbolIdentity(kind="variable", name=normalized)
if "::" in normalized:
return SymbolIdentity(
kind="variable", name=_qualify(normalized, scope.namespace)
)
if scope.proc_name is None:
return SymbolIdentity(
kind="variable", name=_qualify(normalized, scope.namespace)
)
for alias, target in scope.global_variables + scope.namespace_variables:
if normalized == alias:
return SymbolIdentity(kind="variable", name=target)
return SymbolIdentity(
kind="variable",
name=normalized,
scope=f"{scope.filepath}::proc::{scope.proc_name}",
)
def _variable_command_nodes(command: Command) -> list[tuple[Node, bool]]:
routine = _static_contents(command.routine)
if routine == "set" and command.args:
return [(command.args[0], len(command.args) >= 2)]
if routine in {"incr", "append", "lappend"} and command.args:
return [(command.args[0], True)]
if routine == "lset" and command.args:
return [(command.args[0], False)]
if routine == "unset":
return [
(argument, False)
for argument in command.args
if not (_static_contents(argument) or "").startswith("-")
]
if routine == "array" and len(command.args) >= 2:
return [
(command.args[1], _static_contents(command.args[0]) == "set")
]
if routine == "dict" and len(command.args) >= 2:
subcommand = _static_contents(command.args[0])
if subcommand in {"set", "unset", "append", "incr", "lappend", "update", "with"}:
return [
(
command.args[1],
subcommand in {"set", "append", "incr", "lappend"},
)
]
return []
def _binding_nodes(node: Node) -> list[Node]:
if isinstance(node, List):
return list(node.children)
return [node]
def _variable_binding_nodes(command: Command) -> list[Node]:
routine = _static_contents(command.routine)
if routine in {"foreach", "lmap"} and len(command.args) >= 3:
return [
variable
for variable_list in command.args[:-1:2]
for variable in _binding_nodes(variable_list)
]
if routine == "lassign" and len(command.args) >= 2:
return list(command.args[1:])
if routine == "catch" and len(command.args) >= 2:
return list(command.args[1:3])
if (
routine == "dict"
and command.args
and _static_contents(command.args[0]) == "update"
):
return list(command.args[3:-1:2])
return []
def _variable_declaration_nodes(command: Command) -> list[Node]:
routine = _static_contents(command.routine)
if routine == "global":
return list(command.args)
if routine == "variable":
return list(command.args[::2])
return []
def _scan_proc_imports(
node: Node, namespace: str
) -> tuple[dict[str, str], dict[str, str]]:
global_variables: dict[str, str] = {}
namespace_variables: dict[str, str] = {}
def walk(current: Node) -> None:
if isinstance(current, Command):
routine = _static_contents(current.routine)
if routine == "proc":
return
if routine == "global":
for argument in current.args:
name = _static_contents(argument)
if name:
global_variables[_basename(name)] = _qualify(
name, ROOT_NAMESPACE
)
elif routine == "variable":
for argument in current.args[::2]:
name = _static_contents(argument)
if name:
namespace_variables[_basename(name)] = _qualify(
name, namespace
)
for child in getattr(current, "children", []):
walk(child)
walk(node)
return global_variables, namespace_variables
def build_file_symbol_index(
filepath: str, uri: str, tree: Node
) -> FileSymbolIndex:
occurrences: list[SymbolOccurrence] = []
def add_proc(
node: Node,
raw_name: str,
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,
fallback_identity=(
None
if is_definition
else _proc_fallback(raw_name, scope.namespace)
),
range=_name_range(node, raw_name),
placeholder=_basename(raw_name),
is_definition=is_definition,
symbol_kind=lsp.SymbolKind.Function,
container_name=_container_name(identity),
caller=caller,
declaration_range=declaration_range,
)
)
def add_variable(
node: Node,
raw_name: str,
scope: _Scope,
*,
is_definition: bool,
variable_sub: bool = False,
identity: SymbolIdentity | None = None,
) -> None:
symbol_identity = identity or _variable_identity(raw_name, scope)
occurrences.append(
SymbolOccurrence(
identity=symbol_identity,
range=_name_range(node, raw_name, variable_sub=variable_sub),
placeholder=_basename(raw_name),
is_definition=is_definition,
symbol_kind=lsp.SymbolKind.Variable,
container_name=_container_name(symbol_identity),
array_element=(
raw_name.split("(", 1)[1][:-1]
if "(" in raw_name and raw_name.endswith(")")
else None
),
array_parts=array_key_parts(node),
array_template_parts=array_key_parts(node, preserve_variables=True),
)
)
def walk_embedded(node: Node, scope: _Scope) -> None:
if isinstance(node, Script):
walk_script(node, scope)
return
if isinstance(node, Command):
walk_command(node, scope)
return
if isinstance(node, VarSub):
raw_name = getattr(node, "value", None)
if isinstance(raw_name, str):
add_variable(
node,
raw_name,
scope,
is_definition=False,
variable_sub=True,
)
for child in getattr(node, "children", []):
walk_embedded(child, scope)
def walk_proc(command: Command, scope: _Scope) -> None:
if len(command.args) < 3:
return
raw_name = _static_contents(command.args[0])
body = command.args[2]
if raw_name is None or not isinstance(body, Script):
return
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(
body, proc_namespace
)
proc_scope = _Scope(
filepath=filepath,
namespace=proc_namespace,
proc_name=proc_identity.name,
global_variables=tuple(sorted(global_variables.items())),
namespace_variables=tuple(sorted(namespace_variables.items())),
)
parameters = command.args[1]
for parameter in getattr(parameters, "children", []):
parameter_node = parameter
if isinstance(parameter, List) and parameter.children:
parameter_node = parameter.children[0]
parameter_name = _static_contents(parameter_node)
if parameter_name:
add_variable(
parameter_node,
parameter_name,
proc_scope,
is_definition=True,
)
walk_script(body, proc_scope)
def walk_namespace(command: Command, scope: _Scope) -> bool:
if len(command.args) < 3 or _static_contents(command.args[0]) != "eval":
return False
raw_name = _static_contents(command.args[1])
body = command.args[2]
if raw_name is None or not isinstance(body, Script):
return False
namespace = _qualify(raw_name, scope.namespace)
identity = SymbolIdentity(kind="namespace", name=namespace)
occurrences.append(
SymbolOccurrence(
identity=identity,
range=_name_range(command.args[1], raw_name),
placeholder=_basename(raw_name),
is_definition=True,
symbol_kind=lsp.SymbolKind.Namespace,
container_name=_container_name(identity),
)
)
walk_script(
body,
_Scope(filepath=filepath, namespace=namespace),
)
return True
def walk_command(command: Command, scope: _Scope) -> None:
routine = _static_contents(command.routine)
if routine == "proc":
walk_proc(command, scope)
return
if routine == "namespace" and walk_namespace(command, scope):
return
if routine:
add_proc(command.routine, routine, scope, is_definition=False)
declaration_nodes = _variable_declaration_nodes(command)
declaration_ids = {id(node) for node in declaration_nodes}
for node in declaration_nodes:
raw_name = _static_contents(node)
if raw_name:
is_definition = routine == "variable" and scope.proc_name is None
if routine == "global":
identity = SymbolIdentity(
kind="variable",
name=_qualify(raw_name, ROOT_NAMESPACE),
)
else:
identity = SymbolIdentity(
kind="variable",
name=_qualify(raw_name, scope.namespace),
)
add_variable(
node,
raw_name,
scope,
is_definition=is_definition,
identity=identity,
)
for node, is_definition in _variable_command_nodes(command):
if id(node) in declaration_ids:
continue
raw_name = variable_name(node)
if raw_name:
add_variable(
node,
raw_name,
scope,
is_definition=is_definition,
)
for node in _variable_binding_nodes(command):
raw_name = _static_contents(node)
if raw_name:
add_variable(node, raw_name, scope, is_definition=True)
for argument in command.args:
walk_embedded(argument, scope)
def walk_script(script: Node, scope: _Scope) -> None:
for child in getattr(script, "children", []):
walk_embedded(child, scope)
walk_script(tree, _Scope(filepath=filepath))
return FileSymbolIndex(
path=filepath,
uri=uri,
occurrences=tuple(occurrences),
document_range=_node_range(tree),
)
def definition_identities(indexes: dict[str, FileSymbolIndex]) -> set[SymbolIdentity]:
return {
occurrence.identity
for index in indexes.values()
for occurrence in index.occurrences
if occurrence.is_definition
}
def resolve_identity(
occurrence: SymbolOccurrence, definitions: set[SymbolIdentity]
) -> SymbolIdentity:
if occurrence.identity in definitions or occurrence.fallback_identity is None:
return occurrence.identity
if occurrence.fallback_identity in definitions:
return occurrence.fallback_identity
return occurrence.identity
def symbol_at_position(
index: FileSymbolIndex,
position: lsp.Position,
definitions: set[SymbolIdentity],
) -> tuple[SymbolOccurrence, SymbolIdentity] | None:
for occurrence in index.occurrences:
start = occurrence.range.start
end = occurrence.range.end
if (
position.line == start.line == end.line
and start.character <= position.character < end.character
):
return occurrence, resolve_identity(occurrence, definitions)
return None
def matching_occurrences(
identity: SymbolIdentity,
indexes: dict[str, FileSymbolIndex],
definitions: set[SymbolIdentity],
) -> list[tuple[FileSymbolIndex, SymbolOccurrence]]:
matches = []
for index in indexes.values():
for occurrence in index.occurrences:
if resolve_identity(occurrence, definitions) == identity:
matches.append((index, occurrence))
return matches
def document_highlights(
index: FileSymbolIndex,
identity: SymbolIdentity,
definitions: set[SymbolIdentity],
) -> list[lsp.DocumentHighlight]:
"""Return all occurrences of one symbol in the active document."""
highlights = []
for occurrence in index.occurrences:
if resolve_identity(occurrence, definitions) != identity:
continue
kind = lsp.DocumentHighlightKind.Text
if identity.kind == "variable":
kind = (
lsp.DocumentHighlightKind.Write
if occurrence.is_definition
else lsp.DocumentHighlightKind.Read
)
highlights.append(lsp.DocumentHighlight(range=occurrence.range, kind=kind))
return sorted(
highlights,
key=lambda highlight: (
highlight.range.start.line,
highlight.range.start.character,
highlight.range.end.line,
highlight.range.end.character,
),
)
def workspace_symbols(
indexes: dict[str, FileSymbolIndex], query: str
) -> list[lsp.SymbolInformation]:
query = query.casefold()
results = []
seen = set()
for index in indexes.values():
for occurrence in index.occurrences:
identity = occurrence.identity
if not occurrence.is_definition:
continue
if identity.kind == "variable" and identity.scope is not None:
continue
name = _display_name(identity)
if query and query not in name.casefold():
continue
key = identity
if key in seen:
continue
seen.add(key)
results.append(
lsp.SymbolInformation(
name=name,
kind=occurrence.symbol_kind,
location=lsp.Location(uri=index.uri, range=occurrence.range),
container_name=occurrence.container_name,
)
)
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))