From 525a08c2890e797bf941ec30a0e622bc15dcf005 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 25 Sep 2026 08:43:42 +0200 Subject: [PATCH] feat(tools): recognize procedures stored in COMMANDBLOCK properties Add a new module that parses PostConfigurator COMMANDBLOCK values (CONF_* set ...) to extract the first word of braced list elements as procedure names with precise line/column spans. - server/src/tools/stored_procs.py: implement stored_command_names(command) which returns (name, line, column) for static/braced list elements. - Integrate into navigation (build_file_symbol_index) to index these names as non-definitions so Go To Definition / Find References can resolve them. - Integrate into semantic highlighting to mark known stored procedures as functions when appropriate. - Add tests (server/tests/python_tests/test_stored_procs.py) covering parsing, goto-definition, references, and highlighting behavior. - Update CHANGELOG to note the new capability. Notes/constraints: - Only static/braced COMMANDBLOCK values (BracedWord) are considered. - Names must match the command-name pattern and are taken from the first word of each list element. --- CHANGELOG.md | 1 + server/src/tools/navigation.py | 16 +++- server/src/tools/semantic_tokens.py | 6 ++ server/src/tools/stored_procs.py | 80 ++++++++++++++++ .../tests/python_tests/test_stored_procs.py | 92 +++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 server/src/tools/stored_procs.py create mode 100644 server/tests/python_tests/test_stored_procs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a08c4..88a259b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Versions correspond to the Git tags of this repository. ### Added +- Procedures stored in PostConfigurator COMMANDBLOCK properties (`CONF_CTRL_tool set auto_preselect_last_template {custom_header}`) are highlighted as procedures and support Go to Definition, Find References, Rename, and the call hierarchy - Go to Definition from block template and address arguments in Tcl (`MOM_do_template`, `MOM_force`, `MOM_suppress`, `MOM_ask_address_value`, ...) to the `BLOCK_TEMPLATE`/`ADDRESS` declaration in the PSC `.def` files - Hover over block templates shows the template body; hover over addresses shows format, leader, trailer, min/max, and modality - Find References and Rename for block templates and addresses across Tcl and `.def` files, including addresses used inside block templates diff --git a/server/src/tools/navigation.py b/server/src/tools/navigation.py index 2b29f63..abf086f 100644 --- a/server/src/tools/navigation.py +++ b/server/src/tools/navigation.py @@ -15,6 +15,7 @@ from tools.def_flow import ( # noqa: F401 (re-exported) proc_def_flows, ) from tools.def_flow import def_name as _def_name +from tools.stored_procs import stored_command_names from tools.variable_names import array_key_parts, variable_name ROOT_NAMESPACE = "::" @@ -294,6 +295,7 @@ def build_file_symbol_index( *, is_definition: bool, declaration_range: lsp.Range | None = None, + name_range: lsp.Range | None = None, ) -> None: identity = shared(_proc_identity(raw_name, scope.namespace)) caller = None @@ -311,7 +313,7 @@ def build_file_symbol_index( if is_definition else shared(_proc_fallback(raw_name, scope.namespace)) ), - range=_name_range(node, raw_name), + range=name_range or _name_range(node, raw_name), placeholder=_basename(raw_name), is_definition=is_definition, symbol_kind=lsp.SymbolKind.Function, @@ -457,6 +459,18 @@ def build_file_symbol_index( if routine: add_proc(command.routine, routine, scope, is_definition=False) + for name, line, column in stored_command_names(command): + start = column - 1 + (name.rfind("::") + 2 if "::" in name else 0) + add_proc( + command.args[2], + name, + scope, + is_definition=False, + name_range=lsp.Range( + start=lsp.Position(line=line - 1, character=start), + end=lsp.Position(line=line - 1, character=column - 1 + len(name)), + ), + ) if flow_facts: flow_facts[-1].extend(command_flow_facts(command)) diff --git a/server/src/tools/semantic_tokens.py b/server/src/tools/semantic_tokens.py index 974998f..ae51850 100644 --- a/server/src/tools/semantic_tokens.py +++ b/server/src/tools/semantic_tokens.py @@ -5,6 +5,7 @@ import attrs from common.load_data import standard_items from tclint.commands.plugins import PluginManager from tclint.syntax_tree import BareWord, BracedWord, Command, QuotedWord, Visitor +from tools.stored_procs import stored_command_names from tools.variable_names import variable_name from tools.tcloo_symbols import class_symbols from tools.tcloo_completion import _analyze @@ -182,6 +183,11 @@ class _Highlighter(Visitor): line, col = argument.contents_pos self._append_token((line - 1, col - 1), len(argument.contents), "function", []) + # Procedures stored in COMMANDBLOCK properties (CONF_x set prop {proc}). + for stored_name, line, col in stored_command_names(command): + if stored_name in self._custom_function_names or stored_name in _STANDARD_PROC_NAMES: + self._append_token((line - 1, col - 1), len(stored_name), "function", []) + # Highlight functions (custom or standard) when used as the routine name = getattr(routine, "contents", None) if name: diff --git a/server/src/tools/stored_procs.py b/server/src/tools/stored_procs.py new file mode 100644 index 0000000..07a3865 --- /dev/null +++ b/server/src/tools/stored_procs.py @@ -0,0 +1,80 @@ +"""Procedure names stored as data and called later. + +PostConfigurator COMMANDBLOCK properties hold a Tcl list whose elements are +executed as commands (see LIB_CONF_do_prop_custom_proc), e.g. +``CONF_CTRL_tool set auto_preselect_last_template {custom_header}`` or +``CONF_CTRL_moves set return_safety_pos {{OEM_output arg}}``. The first word of +each element is a command name; only braced values are considered. +""" + +from __future__ import annotations + +import re + +from tclint.syntax_tree import BracedWord, Command, Node + +_CONF_OBJECT_RE = re.compile(r"^(::)?CONF_\w+$") +_COMMAND_NAME_RE = re.compile(r"[A-Za-z_:][\w:]*") + + +def _static(node: Node | None) -> str | None: + value = getattr(node, "contents", None) + return value if isinstance(value, str) else None + + +def _list_element_spans(text: str) -> list[tuple[int, int]]: + """(start, end) offsets of the top-level elements of a Tcl list, braces stripped.""" + spans = [] + index = 0 + while index < len(text): + if text[index].isspace(): + index += 1 + continue + if text[index] == "{": + depth, start = 0, index + 1 + while index < len(text): + if text[index] == "\\": + index += 2 + continue + depth += {"{": 1, "}": -1}.get(text[index], 0) + index += 1 + if depth == 0: + break + spans.append((start, index - 1)) + elif text[index] == '"': + start = index + 1 + index = text.find('"', start) + index = len(text) if index < 0 else index + spans.append((start, index)) + index += 1 + else: + start = index + while index < len(text) and not text[index].isspace(): + index += 1 + spans.append((start, index)) + return spans + + +def stored_command_names(command: Command) -> list[tuple[str, int, int]]: + """Command names stored in ``command`` as (name, line, column), 1-based.""" + routine = _static(command.routine) + args = command.args + if not (routine and _CONF_OBJECT_RE.match(routine) and len(args) >= 3 and _static(args[0]) == "set"): + return [] + value = args[2] + text = _static(value) + if not isinstance(value, BracedWord) or not text or value.contents_pos is None: + return [] + line, column = value.contents_pos + names = [] + for start, end in _list_element_spans(text): + element = text[start:end] + match = _COMMAND_NAME_RE.match(element, len(element) - len(element.lstrip())) + if match is None or (match.end() < len(element) and not element[match.end()].isspace()): + continue + offset = start + match.start() + before = text[:offset] + name_line = line + before.count("\n") + name_column = offset - before.rfind("\n") if "\n" in before else column + offset + names.append((match.group(), name_line, name_column)) + return names diff --git a/server/tests/python_tests/test_stored_procs.py b/server/tests/python_tests/test_stored_procs.py new file mode 100644 index 0000000..99b42c0 --- /dev/null +++ b/server/tests/python_tests/test_stored_procs.py @@ -0,0 +1,92 @@ +"""Procedures stored in PostConfigurator COMMANDBLOCK properties.""" + +from pathlib import Path + +import lsprotocol.types as lsp # type: ignore + +from lsp_server import LSP_SERVER, goto_definition, references +from tools.parser import CustomParser +from tools.semantic_tokens import _Highlighter +from tools.stored_procs import stored_command_names + +SOURCE = """proc custom_header {} {} +proc ::ns::output {args} {} +CONF_CTRL_tool set auto_preselect_last_template {custom_header} +CONF_CTRL_moves set return_safety_pos {{::ns::output 1} custom_header} +CONF_CTRL_moves set return_end_of_pgm {4th5th} +CONF_CTRL_tool set auto_preselect_template "custom_header" +set x {custom_header} +""" + + +def _names(source: str) -> list[tuple[str, int, int]]: + tree = CustomParser().parse(source) + return [name for command in tree.children for name in stored_command_names(command)] + + +def test_stored_command_names_take_the_first_word_of_braced_list_elements(): + # Values like {4th5th} are options, not procedure names. + assert _names(SOURCE) == [ + ("custom_header", 3, 50), + ("::ns::output", 4, 41), + ("custom_header", 4, 57), + ] + + +def test_stored_command_names_span_lines_and_skip_non_names(): + source = "CONF_x set p {\n\t{first 1}\n\t\"second\"\n\t{$var}\n\t{a-b}\n}\n" + assert _names(source) == [("first", 2, 3), ("second", 3, 3)] + + +def _document(tmp_path: Path) -> str: + uri = (tmp_path / "stored.tcl").as_uri() + LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=SOURCE)) + LSP_SERVER.update_poco_completion_for_file(LSP_SERVER.workspace.get_text_document(uri)) + return uri + + +def _at(needle: str, occurrence: int = 0) -> lsp.Position: + index = -1 + for _ in range(occurrence + 1): + index = SOURCE.index(needle, index + 1) + line = SOURCE.count("\n", 0, index) + return lsp.Position(line=line, character=index - (SOURCE.rfind("\n", 0, index) + 1) + 1) + + +def test_goto_definition_from_stored_proc(tmp_path): + uri = _document(tmp_path) + for needle, occurrence in (("custom_header", 1), ("custom_header", 2), ("output", 1)): + [location] = goto_definition( + lsp.DefinitionParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=_at(needle, occurrence)) + ) + assert location.range.start.line == (0 if needle == "custom_header" else 1) + + +def test_references_include_stored_procs_but_not_plain_strings(tmp_path): + uri = _document(tmp_path) + found = references( + lsp.ReferenceParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=_at("custom_header"), + context=lsp.ReferenceContext(include_declaration=False), + ) + ) + # The shared server also holds the files of other tests. + assert [(location.range.start.line, location.range.start.character) for location in found if location.uri == uri] == [(2, 49), (3, 56)] + + +def test_stored_procs_are_highlighted_only_when_known(): + tree = CustomParser().parse(SOURCE) + highlighter = _Highlighter([], {"file": [lsp.CompletionItem(label="custom_header")]}) + tree.accept(highlighter, recurse=True) + line = column = 0 + functions = [] + for token in highlighter.tokens(): + column = column + token.offset if token.line == 0 else token.offset + line += token.line + if token.tok_type == "function": + functions.append((line, SOURCE.splitlines()[line][column:column + token.length])) + assert (2, "custom_header") in functions + assert (3, "custom_header") in functions + assert not any(text == "4th5th" for _, text in functions) + assert not any(line in {5, 6} for line, _ in functions)