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.
This commit is contained in:
2026-09-25 08:43:42 +02:00
parent 82951a3911
commit 525a08c289
5 changed files with 194 additions and 1 deletions
+15 -1
View File
@@ -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))
+6
View File
@@ -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:
+80
View File
@@ -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