feat(def_flow): recognize .def names propagated via variables and wrapper procs

Add a new def_flow analysis module that follows .def block template
and address names through local variables and proc parameters, and a
wrapper-table builder to resolve proc arguments that forward .def names.
Derived names are resolved only for hover/definition (not for rename).

Integrate this into the LSP:
- lsp_server: add _word_at and _tcl_def_symbol helpers; fallback to
  derived_def_symbol when direct NX-argument navigation fails for hover,
  goto-definition and references; return proper ranges for hover.
- lsp_tclserver: cache and expose a def_wrapper_table built from index
  def_flows (with cache invalidation on index generation).

Also add unit tests for def_flow and update CHANGELOG to note hover/
definition and completion preview improvements for derived names.
This commit is contained in:
2026-09-25 08:14:59 +02:00
parent 17fb79a346
commit 5b027a2717
6 changed files with 612 additions and 49 deletions
+340
View File
@@ -0,0 +1,340 @@
"""Follow .def block template and address names through variables and procs.
A name is only a .def symbol where it provably reaches an NX command taking one:
directly as an argument (``MOM_do_template steady_rest``), through a variable
of the same scope (``set t steady_rest; MOM_do_template $t``) or through the
parameter of a proc that passes it on (``LIB_SPF_call_cycle absolute_mode``).
Derived names are resolved for hover and definition only, never renamed.
"""
from __future__ import annotations
import re
from collections import defaultdict
from collections.abc import Iterable, Iterator
import lsprotocol.types as lsp
from tclint.syntax_tree import BracedWord, Command, CommandSub, List, Node, QuotedWord, Script, VarSub
from tools.tcl_command_completion import TCL_COMMAND_NAMES
ROOT_NAMESPACE = "::"
DEF_BLOCK_TEMPLATE = "block_template"
DEF_ADDRESS = "address"
DEF_SYMBOL_KINDS = frozenset({DEF_BLOCK_TEMPLATE, DEF_ADDRESS})
# NX commands taking .def names: command -> (first argument index, last index or
# None for all following arguments, kind).
_DEF_ARGUMENTS: dict[str, tuple[tuple[int, int | None, str], ...]] = {
"MOM_do_template": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_add_to_block_buffer": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_polar_motion": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_force_block": ((1, None, DEF_BLOCK_TEMPLATE),),
"MOM_ask_address_value": ((0, 0, DEF_ADDRESS),),
"MOM_add_to_address_buffer": ((0, 0, DEF_ADDRESS),),
"MOM_enable_address": ((0, None, DEF_ADDRESS),),
"MOM_disable_address": ((0, None, DEF_ADDRESS),),
"MOM_force": ((1, None, DEF_ADDRESS),),
"MOM_suppress": ((1, None, DEF_ADDRESS),),
"MOM_incremental": ((1, None, DEF_ADDRESS),),
}
_DEFINITION_ELEMENT_COMMANDS = frozenset({"MOM_ask_definition_element", "MOM_has_definition_element"})
_DEFINITION_ELEMENT_KINDS = {"ADDRESS": DEF_ADDRESS, "BLOCK": DEF_BLOCK_TEMPLATE}
# Commands that never pass a .def name on to a proc parameter.
_NON_FORWARDING = (
frozenset(TCL_COMMAND_NAMES)
| frozenset(_DEF_ARGUMENTS)
| _DEFINITION_ELEMENT_COMMANDS
| frozenset({
"set", "unset", "puts", "expr", "return", "incr", "append", "lappend", "list", "lindex", "lrange",
"llength", "lsearch", "lsort", "lreverse", "lassign", "concat", "join", "split", "format", "regsub",
"regexp", "string", "if", "while", "for", "foreach", "lmap", "switch", "catch", "eval", "uplevel",
"upvar", "global", "variable", "info", "array", "dict", "subst", "error", "proc", "namespace",
})
)
# Commands returning (elements of) their first argument's list value.
_LIST_ACCESSORS = frozenset({"lindex", "lrange", "lsort", "lreverse", "lsearch"})
_LIST_WORD_RE = re.compile(r'"([^"\s{}]*)"|([^\s"{}]+)')
# ("def", kind) or ("call", routine, argument index) where a variable ends up.
FlowTarget = tuple
# Fact: ("sink", variable, target) or ("edge", destination, source variable).
FlowFact = tuple
# Per proc: ((parameter index, ("def", kind) | ("call", qualified, fallback, index)), ...)
ProcDefFlows = tuple[tuple[int, tuple], ...]
WrapperTable = dict[str, dict[int, frozenset[str]]]
def static_contents(node: Node | None) -> str | None:
value = getattr(node, "contents", None)
return value if isinstance(value, str) else None
def def_argument_kinds(command: Command) -> list[tuple[Node, str]]:
"""Return the arguments of ``command`` that name a .def block template or address."""
routine = static_contents(command.routine)
if routine in _DEFINITION_ELEMENT_COMMANDS:
kind = _DEFINITION_ELEMENT_KINDS.get((static_contents(command.args[0]) or "").upper()) if command.args else None
return [(command.args[1], kind)] if kind and len(command.args) >= 2 else []
result = []
for first, last, kind in _DEF_ARGUMENTS.get(routine or "", ()):
for position, argument in enumerate(command.args):
if position >= first and (last is None or position <= last):
result.append((argument, kind))
return result
def def_name(node: Node) -> str | None:
name = static_contents(node)
if not name or node.contents_pos is None or any(char.isspace() or char in "$[]{}\\\"" for char in name):
return None
return name
def qualify(name: str, namespace: str) -> str:
if name.startswith("::"):
return name
return f"::{name}" if namespace == ROOT_NAMESPACE else f"{namespace}::{name}"
def _variable_reference(node: Node) -> str | None:
"""Name of the scalar variable ``node`` consists of: ``$v`` or ``"$v"``."""
if isinstance(node, QuotedWord) and len(node.children) == 1:
node = node.children[0]
if isinstance(node, VarSub) and isinstance(node.value, str) and "(" not in node.value:
return node.value
return None
def _value_sources(node: Node) -> list[str]:
"""Variables whose value or list elements ``node`` copies."""
variable = _variable_reference(node)
if variable is not None:
return [variable]
if isinstance(node, CommandSub) and len(node.children) == 1 and isinstance(node.children[0], Command):
inner = node.children[0]
routine = static_contents(inner.routine)
if routine in _LIST_ACCESSORS and inner.args:
return _value_sources(inner.args[0])
if routine in {"list", "concat"}:
return [source for argument in inner.args for source in _value_sources(argument)]
return []
def _bound_names(node: Node) -> list[str]:
nodes = node.children if isinstance(node, List) else [node]
names = [static_contents(child) for child in nodes]
if len(names) == 1 and names[0] and " " in names[0]:
return names[0].split()
return [name for name in names if name]
def command_flow_facts(command: Command) -> list[FlowFact]:
"""Facts on how ``command`` moves variable values towards .def arguments."""
routine = static_contents(command.routine)
args = command.args
facts: list[FlowFact] = [
("sink", variable, ("def", kind))
for node, kind in def_argument_kinds(command)
if (variable := _variable_reference(node)) is not None
]
if routine == "set" and len(args) == 2:
destination = static_contents(args[0])
if destination:
facts.extend(("edge", destination, source) for source in _value_sources(args[1]))
elif routine == "lappend" and args:
destination = static_contents(args[0])
if destination:
facts.extend(("edge", destination, source) for argument in args[1:] for source in _value_sources(argument))
elif routine in {"foreach", "lmap"} and len(args) >= 3:
for position in range(0, len(args) - 1, 2):
sources = _value_sources(args[position + 1])
facts.extend(("edge", name, source) for name in _bound_names(args[position]) for source in sources)
elif routine == "lassign" and args:
sources = _value_sources(args[0])
facts.extend(("edge", name, source) for node in args[1:] if (name := static_contents(node)) for source in sources)
elif routine and routine not in _NON_FORWARDING:
facts.extend(
("sink", variable, ("call", routine, position))
for position, argument in enumerate(args)
if (variable := _variable_reference(argument)) is not None
)
return facts
def solve_flow(facts: Iterable[FlowFact]) -> dict[str, set[FlowTarget]]:
"""Map each variable to the .def arguments and proc parameters it reaches."""
targets: dict[str, set[FlowTarget]] = defaultdict(set)
sources: dict[str, set[str]] = defaultdict(set)
for fact in facts:
if fact[0] == "sink":
targets[fact[1]].add(fact[2])
elif fact[1] != fact[2]:
sources[fact[1]].add(fact[2])
pending = [variable for variable in targets if variable in sources]
while pending:
destination = pending.pop()
for source in sources.get(destination, ()):
before = len(targets[source])
targets[source] |= targets[destination]
if len(targets[source]) != before:
pending.append(source)
return targets
def proc_def_flows(facts: Iterable[FlowFact], parameters: list[str], namespace: str) -> ProcDefFlows:
"""Where the parameters of a proc end up, with qualified callee names."""
targets = solve_flow(facts)
flows = []
for position, parameter in enumerate(parameters):
if parameter == "args" and position == len(parameters) - 1:
break
for target in targets.get(parameter, ()):
if target[0] == "call":
target = ("call", qualify(target[1], namespace), qualify(target[1], ROOT_NAMESPACE), target[2])
flows.append((position, target))
return tuple(sorted(flows))
def build_wrapper_table(procs: Iterable[tuple[str, ProcDefFlows]]) -> WrapperTable:
"""Resolve which proc arguments take .def names, following nested wrappers."""
kinds: dict[str, dict[int, set[str]]] = defaultdict(lambda: defaultdict(set))
calls = []
for proc, flows in procs:
for position, target in flows:
if target[0] == "def":
kinds[proc][position].add(target[1])
else:
calls.append((proc, position, target[1], target[2], target[3]))
changed = True
while changed:
changed = False
for proc, position, callee, fallback, callee_position in calls:
entry = kinds.get(callee) or kinds.get(fallback)
found = entry.get(callee_position) if entry else None
if found and not found <= kinds[proc][position]:
kinds[proc][position] |= found
changed = True
return {
proc: {position: frozenset(names) for position, names in positions.items() if names}
for proc, positions in kinds.items()
if any(positions.values())
}
def _wrapper_kinds(table: WrapperTable, routine: str, position: int, namespace: str) -> frozenset[str]:
entry = table.get(qualify(routine, namespace)) or table.get(qualify(routine, ROOT_NAMESPACE))
return entry.get(position, frozenset()) if entry else frozenset()
def _target_kinds(target: FlowTarget, table: WrapperTable, namespace: str) -> frozenset[str]:
if target[0] == "def":
return frozenset({target[1]})
return _wrapper_kinds(table, target[1], target[2], namespace)
def _scope_commands(script: Node) -> Iterator[Command]:
"""Commands of one scope, without the bodies of procs defined in it."""
for child in getattr(script, "children", []):
if isinstance(child, Command):
yield child
if static_contents(child.routine) == "proc":
continue
yield from _scope_commands(child)
def _contains(node: Node, point: tuple[int, int]) -> bool:
return node.pos is not None and node.end_pos is not None and node.pos <= point < node.end_pos
def _path_at(tree: Node, point: tuple[int, int]) -> list[Node]:
path = [tree]
while True:
child = next((child for child in getattr(path[-1], "children", []) if _contains(child, point)), None)
if child is None:
return path
path.append(child)
def _literal_at(node: Node, point: tuple[int, int]) -> tuple[str, lsp.Range] | None:
"""The single name ``node`` holds, or the list element of a braced word at ``point``."""
if isinstance(node, BracedWord):
contents = static_contents(node)
if contents is None or node.contents_pos is None:
return None
line, column = node.contents_pos
for match in _LIST_WORD_RE.finditer(contents):
start = match.start(1) if match.group(1) is not None else match.start(2)
name = match.group(1) if match.group(1) is not None else match.group(2)
before = contents[:start]
element_line = line + before.count("\n")
element_column = (start - before.rfind("\n") if "\n" in before else column + start)
if element_line == point[0] and element_column <= point[1] < element_column + len(name):
return name, _range(element_line, element_column, name)
return None
name = def_name(node)
if name is None:
return None
line, column = node.contents_pos
return name, _range(line, column, name)
def _range(line: int, column: int, name: str) -> lsp.Range:
return lsp.Range(
start=lsp.Position(line=line - 1, character=column - 1),
end=lsp.Position(line=line - 1, character=column - 1 + len(name)),
)
def _literal_targets(commands: list[Command], values: list[Node]) -> tuple[list[str], list[FlowTarget]]:
"""Variables and proc arguments a literal flows into; ``values`` are its enclosing words."""
command, value = commands[-1], values[-1]
routine = static_contents(command.routine)
args = list(command.args)
position = next((index for index, argument in enumerate(args) if argument is value), None)
if position is None:
return [], []
if routine in {"list", "concat"} and len(commands) >= 2 and isinstance(values[-2], CommandSub):
return _literal_targets(commands[:-1], values[:-1])
if routine == "set" and position == 1:
destination = static_contents(args[0])
return ([destination] if destination else []), []
if routine == "lappend" and position >= 1:
destination = static_contents(args[0])
return ([destination] if destination else []), []
if routine in {"foreach", "lmap"} and position % 2 == 1 and position < len(args) - 1:
return _bound_names(args[position - 1]), []
if routine and routine not in _NON_FORWARDING:
return [], [("call", routine, position)]
return [], []
def derived_def_symbol(
tree: Node, position: lsp.Position, table: WrapperTable
) -> tuple[frozenset[str], str, lsp.Range] | None:
"""Kinds, name and range of a literal that reaches a .def argument indirectly."""
point = (position.line + 1, position.character + 1)
path = _path_at(tree, point)
commands: list[Command] = []
values: list[Node] = []
scope: Node = tree
namespace = ROOT_NAMESPACE
for parent, child in zip(path, path[1:]):
if isinstance(parent, Command):
commands.append(parent)
values.append(child)
if static_contents(parent.routine) == "proc" and len(parent.args) >= 3 and child is parent.args[2]:
scope = child
name = qualify(static_contents(parent.args[0]) or "", ROOT_NAMESPACE)
namespace = name.rsplit("::", 1)[0] or ROOT_NAMESPACE
if not commands or isinstance(values[-1], Script):
return None
literal = _literal_at(values[-1], point)
if literal is None:
return None
variables, targets = _literal_targets(commands, values)
if variables:
scope_targets = solve_flow(fact for command in _scope_commands(scope) for fact in command_flow_facts(command))
targets.extend(target for variable in variables for target in scope_targets.get(variable, ()))
kinds = frozenset(kind for target in targets for kind in _target_kinds(target, table, namespace))
return (kinds, *literal) if kinds else None
+25 -43
View File
@@ -5,6 +5,16 @@ from pathlib import Path
import lsprotocol.types as lsp
from tclint.syntax_tree import Command, List, Node, QuotedWord, Script, VarSub
from tools.def_flow import ( # noqa: F401 (re-exported)
DEF_ADDRESS,
DEF_BLOCK_TEMPLATE,
DEF_SYMBOL_KINDS,
ProcDefFlows,
command_flow_facts,
def_argument_kinds,
proc_def_flows,
)
from tools.def_flow import def_name as _def_name
from tools.variable_names import array_key_parts, variable_name
ROOT_NAMESPACE = "::"
@@ -39,6 +49,8 @@ class FileSymbolIndex:
uri: str
occurrences: tuple[SymbolOccurrence, ...]
document_range: lsp.Range | None = None
# Procs whose parameters reach .def arguments: (qualified proc name, flows).
def_flows: tuple[tuple[str, ProcDefFlows], ...] = ()
@dataclass(frozen=True)
@@ -114,49 +126,6 @@ def _name_range(node: Node, raw_name: str, *, variable_sub: bool = False) -> lsp
)
DEF_BLOCK_TEMPLATE = "block_template"
DEF_ADDRESS = "address"
DEF_SYMBOL_KINDS = frozenset({DEF_BLOCK_TEMPLATE, DEF_ADDRESS})
# NX commands taking .def names: command -> (first argument index, last index or
# None for all following arguments, kind).
_DEF_ARGUMENTS: dict[str, tuple[tuple[int, int | None, str], ...]] = {
"MOM_do_template": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_add_to_block_buffer": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_polar_motion": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_force_block": ((1, None, DEF_BLOCK_TEMPLATE),),
"MOM_ask_address_value": ((0, 0, DEF_ADDRESS),),
"MOM_add_to_address_buffer": ((0, 0, DEF_ADDRESS),),
"MOM_enable_address": ((0, None, DEF_ADDRESS),),
"MOM_disable_address": ((0, None, DEF_ADDRESS),),
"MOM_force": ((1, None, DEF_ADDRESS),),
"MOM_suppress": ((1, None, DEF_ADDRESS),),
"MOM_incremental": ((1, None, DEF_ADDRESS),),
}
_DEFINITION_ELEMENT_KINDS = {"ADDRESS": DEF_ADDRESS, "BLOCK": DEF_BLOCK_TEMPLATE}
def def_argument_kinds(command: Command) -> list[tuple[Node, str]]:
"""Return the arguments of ``command`` that name a .def block template or address."""
routine = _static_contents(command.routine)
if routine in {"MOM_ask_definition_element", "MOM_has_definition_element"}:
kind = _DEFINITION_ELEMENT_KINDS.get((_static_contents(command.args[0]) or "").upper()) if command.args else None
return [(command.args[1], kind)] if kind and len(command.args) >= 2 else []
result = []
for first, last, kind in _DEF_ARGUMENTS.get(routine or "", ()):
for position, argument in enumerate(command.args):
if position >= first and (last is None or position <= last):
result.append((argument, kind))
return result
def _def_name(node: Node) -> str | None:
name = _static_contents(node)
if not name or node.contents_pos is None or any(char.isspace() or char in "$[]{}\\\"" for char in name):
return None
return name
def _node_range(node: Node) -> lsp.Range | None:
if node.pos is None or node.end_pos is None:
return None
@@ -311,6 +280,9 @@ def build_file_symbol_index(
# Most occurrences repeat a few identities; sharing one object per identity
# keeps the index (and its persistent cache) small.
identities: dict[SymbolIdentity, SymbolIdentity] = {}
def_flows: list[tuple[str, ProcDefFlows]] = []
# Flow facts of the procs being walked, innermost last.
flow_facts: list[list] = []
def shared(identity: SymbolIdentity | None) -> SymbolIdentity | None:
return None if identity is None else identities.setdefault(identity, identity)
@@ -427,11 +399,13 @@ def build_file_symbol_index(
)
parameters = command.args[1]
parameter_names = []
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)
parameter_names.append(parameter_name or "")
if parameter_name:
add_variable(
parameter_node,
@@ -440,7 +414,12 @@ def build_file_symbol_index(
is_definition=True,
)
flow_facts.append([])
walk_script(body, proc_scope)
facts = flow_facts.pop()
flows = proc_def_flows(facts, parameter_names, proc_namespace) if facts else ()
if flows:
def_flows.append((proc_identity.name, flows))
def walk_namespace(command: Command, scope: _Scope) -> bool:
if len(command.args) < 3 or _static_contents(command.args[0]) != "eval":
@@ -478,6 +457,8 @@ def build_file_symbol_index(
if routine:
add_proc(command.routine, routine, scope, is_definition=False)
if flow_facts:
flow_facts[-1].extend(command_flow_facts(command))
for node, kind in def_argument_kinds(command):
name = _def_name(node)
@@ -550,6 +531,7 @@ def build_file_symbol_index(
uri=uri,
occurrences=tuple(occurrences),
document_range=_node_range(tree),
def_flows=tuple(def_flows),
)