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
@@ -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)