Files
nx_post_support/server/src/tools/completion_items.py
T
Christoph Brandau f88a50d4ab feat(tcloo): add document-local TclOO completions, signature help and hints
Add conservative, document-local TclOO type inference and tooling so the server
can offer method completions, signature help, and inlay parameter hints for
statically resolvable TclOO receivers (including `new`/`create`, `my`, and
simple return-chains). Also surface class names as completion items and emit
semantic tokens for class declarations/references.

Notable changes:
- New tcloo_* tools: completion, symbols, and argument parsing; integrated into
  on_completion, signature_help, inlay hint generation and semantic token
  highlighting. Completions are returned early when an OO receiver context is
  detected.
- Use a completion-friendly parser fallback when the main AST fails (TclSyntaxError)
  so editing-in-progress code still yields useful completions.
- Add CompletionItemKind.Class to command kinds, exclude class items from the
  poco completion name cache, and include new unit tests for the TclOO helpers.
2026-09-11 23:38:42 +02:00

272 lines
9.7 KiB
Python

from __future__ import annotations
import copy
import re
from collections.abc import Iterable, Sequence
from enum import Enum
import lsprotocol.types as lsp
from tclint.syntax_tree import BareWord, Command, List, Visitor
from common.load_data import standard_items
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}
class CompletionContext(Enum):
VARIABLE = "variable"
COMMAND = "command"
GENERAL = "general"
VARIABLE_KINDS = {
lsp.CompletionItemKind.Variable,
lsp.CompletionItemKind.Field,
lsp.CompletionItemKind.Constant,
}
COMMAND_KINDS = {
lsp.CompletionItemKind.Class,
lsp.CompletionItemKind.Function,
lsp.CompletionItemKind.Method,
lsp.CompletionItemKind.Constructor,
lsp.CompletionItemKind.Keyword,
lsp.CompletionItemKind.Snippet,
}
_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(
source_lines: Sequence[str], position: lsp.Position
) -> CompletionContext:
prefix = line_prefix_at_position(source_lines, position)
if prefix is None:
return CompletionContext.GENERAL
if _VARIABLE_PREFIX_RE.search(prefix):
return CompletionContext.VARIABLE
if _COMMAND_PREFIX_RE.search(prefix):
return CompletionContext.COMMAND
return CompletionContext.GENERAL
def _is_allowed(item: lsp.CompletionItem, context: CompletionContext) -> bool:
if context == CompletionContext.VARIABLE:
return item.kind in VARIABLE_KINDS
if context == CompletionContext.COMMAND:
return item.kind in COMMAND_KINDS
return True
def ranked_completion_items(
candidates: Iterable[tuple[int, lsp.CompletionItem]],
context: CompletionContext,
) -> list[lsp.CompletionItem]:
"""Filter, de-duplicate, and rank completion candidates for one request."""
items = []
seen: set[tuple[str, lsp.CompletionItemKind | None]] = set()
for sequence, (priority, item) in enumerate(candidates):
if not _is_allowed(item, context):
continue
key = (item.label, item.kind)
if key in seen:
continue
seen.add(key)
ranked = copy.copy(item)
ranked.sort_text = f"{priority:03d}:{item.label.casefold()}:{sequence:06d}"
items.append(ranked)
return items
class CompletionItems:
def __init__(self):
self._custom_functions: list[lsp.CompletionItem] = []
@property
def custom_functions(self) -> list[lsp.CompletionItem]:
return self._custom_functions
@custom_functions.setter
def custom_functions(self, value: lsp.CompletionItem):
self._custom_functions.append(value)
class CompletionCollector(Visitor):
def __init__(self):
super().__init__()
self._custom_functions: list[lsp.CompletionItem] = []
self._custom_function_keys: set[tuple[str, lsp.CompletionItemKind | None]] = set()
self._proc_signatures = {}
@property
def custom_functions(self) -> list[lsp.CompletionItem]:
return self._custom_functions
@property
def proc_signatures(self):
return self._proc_signatures
def _append_unique(self, item: lsp.CompletionItem):
# Avoid duplicate labels within the same file scan
key = (item.label, item.kind)
if key in self._custom_function_keys:
return
self._custom_function_keys.add(key)
self._custom_functions.append(item)
def visit_command(self, command: Command):
routine = command.routine
# Collect custom proc names and their signatures
if routine.contents == "proc" and command.args:
first_arg = command.args[0]
if not getattr(first_arg, "value", None):
return
if first_arg.value in BUILTIN_PROC_LABELS:
return
# Record proc name as a completion item
self._append_unique(lsp.CompletionItem(label=first_arg.value, kind=lsp.CompletionItemKind.Function))
if len(command.args) < 2:
return
param_list_node = command.args[1]
if not hasattr(param_list_node, "children"):
return
param_names = []
for arg in param_list_node.children:
if isinstance(arg, BareWord):
param_names.append(arg.value)
elif isinstance(arg, List) and len(arg.children) >= 1:
first = arg.children[0]
if isinstance(first, BareWord):
param_names.append(first.value)
self._proc_signatures[first_arg.value] = param_names
# Collect global variables declared with: global var1 var2 ...
elif routine.contents == "global" and command.args:
for arg in command.args:
if (
isinstance(arg, BareWord)
and getattr(arg, "value", None)
and arg.value not in BUILTIN_VAR_LABELS
):
self._append_unique(
lsp.CompletionItem(
label=arg.value,
kind=lsp.CompletionItemKind.Variable,
)
)
# Collect variables set with explicit global namespace: set ::var_name ...
elif routine.contents == "set" and command.args:
first = command.args[0]
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
if clean_name not in BUILTIN_VAR_LABELS:
self._append_unique(lsp.CompletionItem(label=clean_name, kind=lsp.CompletionItemKind.Variable))