feat(tcl): add static variable name extraction and array key completion

This adds tooling to statically extract Tcl variable names from syntax
trees without evaluating substitutions, enabling better completions
for array elements and plain variables. The new helpers are wired
into the completion and navigation flows and are supported by tests
covering array keys and substitutions.

- Introduce variable_names.py with variable_name() and array_key_parts()
- Wire static name extraction into completion and symbol indexing
- Add tests for array key completion with substitutions
This commit is contained in:
Christoph Brandau
2026-09-10 09:07:18 +02:00
parent 7e9a4359cd
commit d8611d7aea
7 changed files with 300 additions and 8 deletions
@@ -103,6 +103,133 @@ def _argument_completion_labels(source: str) -> set[str] | None:
return {item.label for item in completion.items}
def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch):
server, _, _ = _completion_server(tmp_path, monkeypatch)
workspace = _document(
tmp_path / "arrays.tcl",
"set ::lib_flag(enabled) 1\nset ::lib_flag(external) 1\n",
)
assert server.update_poco_completion_for_file(workspace)
source = (
"set lib_flag(enabled) 0\n"
"set lib_flag(empty) 1\n"
"set other(wrong) 1\n"
"proc hidden {} { set lib_flag(private) 1 }\n"
"set lib_flag()\n"
"puts $lib_flag(en)\n"
"puts 😀; set lib_flag(em\n"
)
current = _document(tmp_path / "arrays-current.tcl", source)
server.workspace.put_text_document(lsp.TextDocumentItem(
uri=current.uri, language_id="tcl", version=1, text=source,
))
assert server.update_poco_completion_for_file(current)
items = _complete(current, _position_after(source, "set lib_flag(", 3))
assert [item.label for item in items] == ["empty", "enabled", "external"]
assert all(item.text_edit.new_text == item.label for item in items)
items = _complete(current, _position_after(source, "puts $lib_flag(en"))
assert [item.label for item in items] == ["enabled"]
assert items[0].text_edit.new_text == "enabled"
position = lsp.Position(line=6, character=len(source.splitlines()[6].encode("utf-16-le")) // 2)
items = _complete(current, position)
assert [item.label for item in items] == ["empty"]
assert items[0].text_edit.new_text == "empty)"
assert items[0].text_edit.range.start.character == position.character - 2
def test_dynamic_array_index_keeps_variable_identity(tmp_path: Path, monkeypatch):
from tools.navigation import build_file_symbol_index
from tools.parser import CustomParser
from tools.semantic_tokens import _Highlighter
from tools.variable_index import build_variable_index
from tools.variable_names import variable_name
source = (
"set custom_flag(from_move,$::mom_path_name) 1\n"
'set "::quoted_flag(from_move,$::mom_path_name)" 1\n'
"set ::command_flag([info hostname]) 1\n"
"set ${dynamic_name}(entry) 1\n"
"proc example {} { set local_flag($::mom_path_name) 1 }\n"
"puts $custom_flag\n"
)
tree = CustomParser().parse(source)
globals_, locals_, _ = build_variable_index(source, tree)
assert {"custom_flag", "quoted_flag", "command_flag"} <= globals_
assert locals_["example"] == {"local_flag"}
assert variable_name(tree.children[3].args[0]) is None
path = tmp_path / "dynamic.tcl"
index = build_file_symbol_index(str(path), path.as_uri(), tree)
definition = next(
item for item in index.occurrences
if item.identity.name == "::custom_flag" and item.is_definition
)
assert definition.range.start.character == 4
assert definition.range.end.character == 15
assert definition.array_element is None
assert any(item.identity.name == "::mom_path_name" for item in index.occurrences)
highlighter = _Highlighter([], {})
tree.accept(highlighter, recurse=True)
assert any(
position == (0, 4) and length == 11 and kind == "variable"
for position, length, kind, _ in highlighter._tokens
)
server, _, _ = _completion_server(tmp_path, monkeypatch)
current = _document(path, source)
server.workspace.put_text_document(lsp.TextDocumentItem(
uri=current.uri, language_id="tcl", version=1, text=source,
))
assert server.update_poco_completion_for_file(current)
items = _complete(current, _position_after(source, "puts $custom"))
assert "custom_flag" in {item.label for item in items}
workspace_items = next(
items for item_path, items in server.completion_items_by_file_snapshot().items()
if server.paths_equal(item_path, str(path))
)
assert {"quoted_flag", "command_flag"} <= {item.label for item in workspace_items}
def test_literal_array_components_complete_around_substitutions(tmp_path: Path, monkeypatch):
from tools.completion_items import array_element_completions
server, _, _ = _completion_server(tmp_path, monkeypatch)
source = (
"set custom_flag(from_move,$::mom_path_name) 1\n"
"set custom_flag(to_move,$::mom_path_name) 1\n"
"set custom_flag($::mom_path_name,finished) 1\n"
"set custom_flag(prefix_$::mom_path_name,other) 1\n"
"set custom_flag([info hostname],command_tail) 1\n"
"set other_flag(wrong,$::mom_path_name) 1\n"
"set multi_flag(move,$first,axis,$second) 1\n"
)
document = _document(tmp_path / "components.tcl", source)
assert server.update_poco_completion_for_file(document)
cases = [
("set custom_flag(|,$::mom_path_name)", {"from_move", "to_move"}, "from_move", "set custom_flag(from_move,$::mom_path_name)"),
("set custom_flag(fr|om_old,$::mom_path_name)", {"from_move"}, "from_move", "set custom_flag(from_move,$::mom_path_name)"),
("puts $custom_flag($::mom_path_name,fi|)", {"finished"}, "finished", "puts $custom_flag($::mom_path_name,finished)"),
("set custom_flag($::mom_path_name,|)", {"finished", "other", "command_tail"}, "other", "set custom_flag($::mom_path_name,other)"),
("set custom_flag(fr|", {"from_move"}, "from_move", "set custom_flag(from_move,$::mom_path_name)"),
("set custom_flag(fr|)", {"from_move"}, "from_move", "set custom_flag(from_move,$::mom_path_name)"),
("set multi_flag(m|)", {"move"}, "move", "set multi_flag(move,$first,axis,$second)"),
]
for marked, labels, selected, expected in cases:
offset = marked.index("|")
line = marked.replace("|", "")
items = array_element_completions(
[line], lsp.Position(line=0, character=offset),
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"),
)
assert {item.label for item in items} == labels
edit = next(item.text_edit for item in items if item.label == selected)
item = next(item for item in items if item.label == selected)
assert item.insert_text_format == lsp.InsertTextFormat.PlainText
assert line[:edit.range.start.character] + edit.new_text + line[edit.range.end.character:] == expected
assert array_element_completions(
["set custom_flag(from_move,$::mom"], lsp.Position(line=0, character=31),
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"),
) is None
def _argument_completion_request(source: str):
lines = source.split("\n")
character = len(lines[-1].encode("utf-16-le")) // 2