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
+97 -3
View File
@@ -9,7 +9,13 @@ import lsprotocol.types as lsp
from tclint.syntax_tree import BareWord, Command, List, Visitor
from common.load_data import standard_items
from tools.tcl_command_completion import line_prefix_at_position
from tools.navigation import FileSymbolIndex
from tools.variable_names import variable_name
from tools.tcl_command_completion import (
DynamicCompletionKind,
line_prefix_at_position,
tcl_argument_completion,
)
BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables}
BUILTIN_PROC_LABELS = {ci.label for ci in standard_items.nx_procs}
@@ -36,6 +42,94 @@ COMMAND_KINDS = {
_VARIABLE_PREFIX_RE = re.compile(r"(?<!\\)\$(?:\{)?[A-Za-z0-9_:]*$")
_COMMAND_PREFIX_RE = re.compile(r"(?:^|[;\[\{])\s*[^\s;\[\]\{\}]*$")
_ARRAY_PREFIX_RE = re.compile(
r"(?P<name>(?:::)?[A-Za-z_][A-Za-z0-9_:]*)\((?P<key>[^()\n]*)$"
)
def array_element_completions(
source_lines: Sequence[str],
position: lsp.Position,
indexes: Iterable[FileSymbolIndex],
current_path: str,
) -> list[lsp.CompletionItem] | None:
"""Complete literal array keys, replacing only the text inside parentheses."""
prefix = line_prefix_at_position(source_lines, position)
if prefix is None:
return None
match = _ARRAY_PREFIX_RE.search(prefix)
if match is None:
return None
before = prefix[:match.start()]
if not before.endswith("$"):
argument = tcl_argument_completion(source_lines, position)
if argument is None or argument.dynamic_kind != DynamicCompletionKind.VARIABLE:
return None
name, key_prefix = match.group("name", "key")
part_index = key_prefix.count(",")
key_prefix = key_prefix.rsplit(",", 1)[-1]
if any(char in key_prefix for char in "$[]{}\\"):
return None
indexes = list(indexes)
local_scope = None
for index in indexes:
if index.path != current_path:
continue
for occurrence in index.occurrences:
span = occurrence.declaration_range
if (
occurrence.identity.kind == "proc"
and occurrence.is_definition
and span is not None
and span.start.line <= position.line <= span.end.line
):
local_scope = f"{index.path}::proc::{occurrence.identity.name}"
keys: dict[str, set[tuple[str | None, ...]]] = {}
for index in indexes:
for occurrence in index.occurrences:
parts = occurrence.array_parts
key = parts[part_index] if part_index < len(parts) else None
if (
key
and (
occurrence.identity.scope is None
or occurrence.identity.scope == local_scope
)
and key.startswith(key_prefix)
and not any(char in key for char in "$[]\\")
and occurrence.identity.name.removeprefix("::") == name.removeprefix("::")
):
keys.setdefault(key, set()).add(occurrence.array_template_parts[part_index + 1:])
line = source_lines[position.line]
suffix = line[len(prefix):]
remaining = re.match(r"[^(),\s$\[\]{}]*", suffix).group()
has_close = suffix[len(remaining):].startswith((")", ","))
start = position.character - len(key_prefix.encode("utf-16-le")) // 2
end = position.character + len(remaining.encode("utf-16-le")) // 2
items = []
for key in sorted(keys):
new_text = key
# Fill missing index components only; keep an existing comma and suffix.
if not suffix[len(remaining):].startswith(","):
tails = sorted(keys[key], key=lambda tail: (len(tail), repr(tail)))
tail = tails[0]
if all(part is not None for part in tail) and any("$" in part for part in tail):
new_text = ",".join([key, *tail])
new_text += "" if has_close else ")"
items.append(lsp.CompletionItem(
label=key,
kind=lsp.CompletionItemKind.Field,
detail=f"{name}({key})",
insert_text_format=lsp.InsertTextFormat.PlainText,
text_edit=lsp.TextEdit(
range=lsp.Range(
start=lsp.Position(line=position.line, character=start),
end=lsp.Position(line=position.line, character=end),
),
new_text=new_text,
),
))
return items
def completion_context(
@@ -167,8 +261,8 @@ class CompletionCollector(Visitor):
# Collect variables set with explicit global namespace: set ::var_name ...
elif routine.contents == "set" and command.args:
first = command.args[0]
if isinstance(first, BareWord) and getattr(first, "value", None):
var_name = first.value
var_name = variable_name(first)
if var_name:
if var_name.startswith("::"):
base_name = var_name.split("(", 1)[0]
clean_name = base_name[2:] # remove leading '::' for completion display