"""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 def _all_commands(node: Node) -> Iterator[Command]: for child in getattr(node, "children", []): if isinstance(child, Command): yield child yield from _all_commands(child) def unknown_def_names(tree: Node, declared: dict[str, frozenset[str]]) -> list[tuple[str, str, lsp.Range]]: """Literal NX command arguments naming a block template or address no .def file declares. ``declared`` maps each kind to its declared names; kinds without any declaration are not checked, since their .def file is not loaded. """ unknown = [] for command in _all_commands(tree): for node, kind in def_argument_kinds(command): names = declared.get(kind) name = def_name(node) if names else None if name is not None and name not in names: line, column = node.contents_pos unknown.append((kind, name, _range(line, column, name))) return unknown