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:
@@ -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)
|
||||
Reference in New Issue
Block a user