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:
@@ -47,6 +47,7 @@ from common.load_data import standard_items
|
||||
from lsp_tclserver import TclLanguageServer
|
||||
from tools.completion_items import (
|
||||
CompletionContext,
|
||||
array_element_completions,
|
||||
completion_context,
|
||||
ranked_completion_items,
|
||||
)
|
||||
@@ -271,12 +272,18 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams):
|
||||
|
||||
@LSP_SERVER.feature(
|
||||
lsp.TEXT_DOCUMENT_COMPLETION,
|
||||
lsp.CompletionOptions(trigger_characters=["$", " ", "-"]),
|
||||
lsp.CompletionOptions(trigger_characters=["$", " ", "-", "(", ","]),
|
||||
)
|
||||
def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
||||
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||
position = params.position
|
||||
source_lines = LSP_SERVER.get_lines(doc)
|
||||
array_items = array_element_completions(
|
||||
source_lines, position, LSP_SERVER.navigation_snapshot().values(),
|
||||
str(pathlib.Path(uris.to_fs_path(doc.uri))),
|
||||
)
|
||||
if array_items is not None:
|
||||
return lsp.CompletionList(is_incomplete=False, items=array_items)
|
||||
context = completion_context(source_lines, position)
|
||||
|
||||
# Variable completion wins inside command arguments. Otherwise prefer the
|
||||
@@ -323,7 +330,7 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
||||
and params.context is not None
|
||||
and params.context.trigger_kind
|
||||
== lsp.CompletionTriggerKind.TriggerCharacter
|
||||
and params.context.trigger_character in {" ", "-"}
|
||||
and params.context.trigger_character in {" ", "-", "(", ","}
|
||||
):
|
||||
return lsp.CompletionList(is_incomplete=False, items=[])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import lsprotocol.types as lsp
|
||||
from tclint.syntax_tree import Command, List, Node, Script, VarSub
|
||||
from tclint.syntax_tree import Command, List, Node, QuotedWord, Script, VarSub
|
||||
from tools.variable_names import array_key_parts, variable_name
|
||||
|
||||
ROOT_NAMESPACE = "::"
|
||||
|
||||
@@ -27,6 +28,9 @@ class SymbolOccurrence:
|
||||
fallback_identity: SymbolIdentity | None = None
|
||||
caller: SymbolIdentity | None = None
|
||||
declaration_range: lsp.Range | None = None
|
||||
array_element: str | None = None
|
||||
array_parts: tuple[str | None, ...] = ()
|
||||
array_template_parts: tuple[str | None, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -96,6 +100,8 @@ def _name_range(node: Node, raw_name: str, *, variable_sub: bool = False) -> lsp
|
||||
column += 2 if getattr(node, "braced", False) else 1
|
||||
else:
|
||||
position = getattr(node, "contents_pos", None) or node.pos
|
||||
if isinstance(node, QuotedWord) and node.contents is None and node.children:
|
||||
position = node.children[0].pos
|
||||
line, column = position
|
||||
|
||||
normalized = _without_array_index(raw_name)
|
||||
@@ -312,6 +318,13 @@ def build_file_symbol_index(
|
||||
is_definition=is_definition,
|
||||
symbol_kind=lsp.SymbolKind.Variable,
|
||||
container_name=_container_name(symbol_identity),
|
||||
array_element=(
|
||||
raw_name.split("(", 1)[1][:-1]
|
||||
if "(" in raw_name and raw_name.endswith(")")
|
||||
else None
|
||||
),
|
||||
array_parts=array_key_parts(node),
|
||||
array_template_parts=array_key_parts(node, preserve_variables=True),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -444,7 +457,7 @@ def build_file_symbol_index(
|
||||
for node, is_definition in _variable_command_nodes(command):
|
||||
if id(node) in declaration_ids:
|
||||
continue
|
||||
raw_name = _static_contents(node)
|
||||
raw_name = variable_name(node)
|
||||
if raw_name:
|
||||
add_variable(
|
||||
node,
|
||||
|
||||
@@ -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, Command, QuotedWord, Visitor
|
||||
from tools.variable_names import variable_name
|
||||
|
||||
# Constructing a PluginManager scans entry points, and get_commands() rebuilds
|
||||
# the builtin command set on every call. Semantic tokens are requested often, so
|
||||
@@ -159,6 +160,11 @@ class _Highlighter(Visitor):
|
||||
if routine.contents == "set" and command.args:
|
||||
first_arg = command.args[0]
|
||||
token_info = self._get_token_info(first_arg)
|
||||
if first_arg.contents is None:
|
||||
name = variable_name(first_arg)
|
||||
if name:
|
||||
line, col = first_arg.children[0].pos
|
||||
token_info = ((line - 1, col - 1), len(name))
|
||||
if token_info:
|
||||
(line, col), length = token_info
|
||||
self._append_token((line, col), length, "variable", [TokenModifier.declaration])
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
|
||||
from tclint.syntax_tree import Command, Node, Script
|
||||
from tclint.syntax_tree import List as TclList
|
||||
from tools.variable_names import variable_name
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -81,7 +82,7 @@ def build_variable_index(
|
||||
return
|
||||
|
||||
if routine == "set" and node.args:
|
||||
raw_name = getattr(node.args[0], "contents", None)
|
||||
raw_name = variable_name(node.args[0])
|
||||
base = _normalize_var_name(raw_name)
|
||||
if base is not None:
|
||||
if raw_name and raw_name.startswith("::"):
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Extract statically known variable names without evaluating Tcl substitutions."""
|
||||
|
||||
from tclint.syntax_tree import BareWord, CompoundBareWord, Node, QuotedWord, VarSub
|
||||
|
||||
|
||||
def variable_name(node: Node) -> str | None:
|
||||
contents = node.contents
|
||||
if isinstance(contents, str):
|
||||
return contents
|
||||
if isinstance(node, (CompoundBareWord, QuotedWord)) and node.children:
|
||||
first = node.children[0]
|
||||
last = node.children[-1]
|
||||
if (
|
||||
isinstance(first, BareWord)
|
||||
and isinstance(last, BareWord)
|
||||
and "(" in first.value
|
||||
and last.value.endswith(")")
|
||||
):
|
||||
# Substitutions in an array index do not change the array's name.
|
||||
return first.value.split("(", 1)[0] or None
|
||||
return None
|
||||
|
||||
|
||||
def array_key_parts(node: Node, *, preserve_variables: bool = False) -> tuple[str | None, ...]:
|
||||
"""Keep comma-separated literal index components; substitutions are unknown."""
|
||||
contents = node.contents
|
||||
if not isinstance(contents, str):
|
||||
if not isinstance(node, (CompoundBareWord, QuotedWord)) or not variable_name(node):
|
||||
return ()
|
||||
chunks = []
|
||||
for child in node.children:
|
||||
if isinstance(child, BareWord):
|
||||
chunks.append(child.value)
|
||||
elif preserve_variables and isinstance(child, VarSub) and not child.children:
|
||||
chunks.append("${" + child.value + "}" if child.braced else "$" + child.value)
|
||||
else:
|
||||
chunks.append("\0")
|
||||
contents = "".join(chunks)
|
||||
if "(" not in contents or not contents.endswith(")"):
|
||||
return ()
|
||||
return tuple(
|
||||
part if part and not any(char in part for char in ("\0[]\\" if preserve_variables else "\0$[]\\")) else None
|
||||
for part in contents.split("(", 1)[1][:-1].split(",")
|
||||
)
|
||||
Reference in New Issue
Block a user