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.
This commit is contained in:
@@ -33,6 +33,7 @@ VARIABLE_KINDS = {
|
||||
lsp.CompletionItemKind.Constant,
|
||||
}
|
||||
COMMAND_KINDS = {
|
||||
lsp.CompletionItemKind.Class,
|
||||
lsp.CompletionItemKind.Function,
|
||||
lsp.CompletionItemKind.Method,
|
||||
lsp.CompletionItemKind.Constructor,
|
||||
|
||||
@@ -9,6 +9,8 @@ from typing import Any
|
||||
import lsprotocol.types as lsp
|
||||
from tclint.syntax_tree import Command, VarSub, Visitor
|
||||
from tools.navigation import FileSymbolIndex
|
||||
from tools.tcloo_arguments import method_parameters
|
||||
from tools.tcloo_completion import resolved_method_calls
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -160,6 +162,7 @@ class InlayHintGenerator(Visitor):
|
||||
self.source_lines = (
|
||||
source_lines if source_lines is not None else source.splitlines()
|
||||
)
|
||||
self.source = source
|
||||
self.proc_signatures = proc_signatures
|
||||
self.requested_range = requested_range
|
||||
self.parameter_names = parameter_names
|
||||
@@ -194,6 +197,17 @@ class InlayHintGenerator(Visitor):
|
||||
walk(child)
|
||||
|
||||
walk(tree)
|
||||
if self.parameter_names != "none":
|
||||
for call in resolved_method_calls(self.source):
|
||||
if not self._node_intersects_requested_range(call.command):
|
||||
continue
|
||||
parameters = method_parameters(call.parameters)
|
||||
signature = InlayHintSignature(
|
||||
parameters=tuple(InlayHintParameter(p.name, variadic=p.variadic) for p in parameters),
|
||||
display_label=" ".join([call.label, *(p.label for p in parameters)]),
|
||||
)
|
||||
self._argument_hints(signature, call.command.args[call.argument_offset:])
|
||||
self.hints.sort(key=lambda hint: (hint.position.line, hint.position.character))
|
||||
return self.hints
|
||||
|
||||
def _position(self, line: int, column: int) -> lsp.Position:
|
||||
@@ -238,7 +252,10 @@ class InlayHintGenerator(Visitor):
|
||||
if signature is None or self.parameter_names == "none":
|
||||
return
|
||||
|
||||
for argument_index, argument in enumerate(command.args):
|
||||
self._argument_hints(signature, command.args)
|
||||
|
||||
def _argument_hints(self, signature, arguments):
|
||||
for argument_index, argument in enumerate(arguments):
|
||||
parameter = self._parameter_for_argument(signature, argument_index)
|
||||
if parameter is None:
|
||||
break
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
from tools.tcloo_symbols import class_symbols
|
||||
|
||||
# Constructing a PluginManager scans entry points, and get_commands() rebuilds
|
||||
# the builtin command set on every call. Semantic tokens are requested often, so
|
||||
@@ -70,6 +71,7 @@ class _Highlighter(Visitor):
|
||||
def __init__(self, plugins, custom_functions):
|
||||
self._commands = _load_commands(plugins)
|
||||
self._tokens = []
|
||||
self._class_tokens = {}
|
||||
if isinstance(custom_functions, dict):
|
||||
self._custom_function_names = frozenset(
|
||||
item.label
|
||||
@@ -84,6 +86,17 @@ class _Highlighter(Visitor):
|
||||
return
|
||||
self._tokens.append((position, length, tok_type, modifiers or []))
|
||||
|
||||
def highlight_classes(self, tree):
|
||||
declarations, references = class_symbols(tree)
|
||||
for node, modifiers in [
|
||||
*((node, [TokenModifier.declaration]) for node in declarations.values()),
|
||||
*((node, []) for node in references),
|
||||
]:
|
||||
line, col = node.contents_pos
|
||||
self._class_tokens[(line - 1, col - 1)] = (
|
||||
(line - 1, col - 1), len(node.contents), "class", modifiers,
|
||||
)
|
||||
|
||||
def _get_token_info(self, node):
|
||||
"""Hilfsmethode um Token-Informationen aus verschiedenen Node-Typen zu extrahieren."""
|
||||
if not hasattr(node, "pos"):
|
||||
@@ -204,7 +217,9 @@ class _Highlighter(Visitor):
|
||||
tokens = []
|
||||
last_line = 0
|
||||
last_col = 0
|
||||
for (line, col), length, tok_type, tok_modifier in sorted(self._tokens, key=lambda x: x[0]):
|
||||
raw_tokens = [token for token in self._tokens if token[0] not in self._class_tokens]
|
||||
raw_tokens.extend(self._class_tokens.values())
|
||||
for (line, col), length, tok_type, tok_modifier in sorted(raw_tokens, key=lambda x: x[0]):
|
||||
line_delta = line - last_line
|
||||
col_delta = col
|
||||
if line == last_line:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Parameter presentation for statically resolved TclOO calls."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import lsprotocol.types as lsp
|
||||
from tclint.lexer import TclSyntaxError
|
||||
from tclint.syntax_tree import BracedWord, Command
|
||||
|
||||
from tools.parser import CustomParser
|
||||
from tools.signature_help import _active_argument, _contains_cursor
|
||||
from tools.tcloo_completion import resolved_method_calls
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MethodParameter:
|
||||
name: str
|
||||
label: str
|
||||
variadic: bool = False
|
||||
|
||||
|
||||
def method_parameters(parameters: str) -> list[MethodParameter]:
|
||||
parser = CustomParser()
|
||||
try:
|
||||
words = parser.parse_list(BracedWord(parameters, pos=(1, 1))).children
|
||||
result = []
|
||||
for index, word in enumerate(words):
|
||||
parts = parser.parse_list(word).children
|
||||
if not parts or len(parts) > 2:
|
||||
return []
|
||||
name = parts[0].contents
|
||||
if name is None:
|
||||
return []
|
||||
variadic = name == "args" and len(parts) == 1 and index == len(words) - 1
|
||||
label = "{" + word.contents + "}" if len(parts) == 2 else name
|
||||
result.append(MethodParameter(name, label, variadic))
|
||||
return result
|
||||
except TclSyntaxError:
|
||||
return []
|
||||
|
||||
|
||||
def method_signature_help(source: str, position: lsp.Position) -> lsp.SignatureHelp | None:
|
||||
lines = source.split("\n")
|
||||
if position.line >= len(lines):
|
||||
return None
|
||||
# AST columns are codepoints; LSP columns are UTF-16 code units.
|
||||
prefix = lines[position.line].encode("utf-16-le")[:position.character * 2].decode("utf-16-le", errors="ignore")
|
||||
cursor = (position.line, len(prefix))
|
||||
candidates = [call for call in resolved_method_calls(source)
|
||||
if _contains_cursor(call.command, lines, cursor)]
|
||||
if not candidates:
|
||||
return None
|
||||
call = max(candidates, key=lambda candidate: candidate.command.pos)
|
||||
|
||||
def nested_active(node):
|
||||
return any(
|
||||
isinstance(child, Command) and _contains_cursor(child, lines, cursor)
|
||||
or nested_active(child)
|
||||
for child in node.children
|
||||
)
|
||||
|
||||
# Let the inner command's own signature provider handle its arguments.
|
||||
if nested_active(call.command):
|
||||
return None
|
||||
argument = _active_argument(call.command, cursor) - call.argument_offset
|
||||
if argument < 0:
|
||||
return None
|
||||
parameters = method_parameters(call.parameters)
|
||||
label = call.label
|
||||
infos = []
|
||||
for parameter in parameters:
|
||||
label += " "
|
||||
start = len(label.encode("utf-16-le")) // 2
|
||||
label += parameter.label
|
||||
infos.append(lsp.ParameterInformation(label=(start, len(label.encode("utf-16-le")) // 2)))
|
||||
active = min(argument, len(parameters) - 1) if parameters else None
|
||||
return lsp.SignatureHelp(
|
||||
signatures=[lsp.SignatureInformation(label=label, parameters=infos, active_parameter=active)],
|
||||
active_signature=0, active_parameter=active,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Conservative, document-local TclOO type inference without executing Tcl."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
|
||||
import lsprotocol.types as lsp
|
||||
from tclint.lexer import TclSyntaxError
|
||||
from tclint.syntax_tree import BracedWord, Command, CommandSub, Script, VarSub
|
||||
|
||||
from tools.parser import CustomParser
|
||||
from tools.tcl_command_completion import line_prefix_at_position
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassInfo:
|
||||
methods: dict[str, tuple[str, Script | None]] = field(default_factory=dict)
|
||||
namespace: str = ""
|
||||
constructor: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MethodCall:
|
||||
command: Command
|
||||
label: str
|
||||
parameters: str
|
||||
argument_offset: int = 1
|
||||
|
||||
|
||||
def parse_completion_source(source, pos=None):
|
||||
# Complete open delimiters while editing; never evaluate the user's code.
|
||||
for _ in range(16):
|
||||
try:
|
||||
return CustomParser().parse(source, pos=pos)
|
||||
except TclSyntaxError as error:
|
||||
message = str(error)
|
||||
closing = next((char for text, char in (
|
||||
("end of command substitution", "]"),
|
||||
("match for brace", "}"), ("match for quote", '"'),
|
||||
) if text in message), None)
|
||||
if closing is None:
|
||||
return None
|
||||
source += closing
|
||||
return None
|
||||
|
||||
|
||||
def _body(node):
|
||||
if isinstance(node, Script):
|
||||
return node
|
||||
if isinstance(node, BracedWord):
|
||||
return parse_completion_source(node.contents, node.contents_pos)
|
||||
return None
|
||||
|
||||
|
||||
def _qualified(name, namespace):
|
||||
return name if name.startswith("::") else f"{namespace}::{name}"
|
||||
|
||||
|
||||
def tcloo_completions(
|
||||
source_lines: Sequence[str], position: lsp.Position,
|
||||
) -> list[lsp.CompletionItem] | None:
|
||||
"""Return receiver-specific methods, or None outside a known OO context."""
|
||||
prefix = line_prefix_at_position(source_lines, position)
|
||||
if prefix is None:
|
||||
return None
|
||||
match = re.search(r"[\w:]*$", prefix)
|
||||
typed = match.group()
|
||||
# Only a method word, never a variable substitution or method argument.
|
||||
word_start = len(prefix) - len(typed)
|
||||
if word_start == 0 or prefix[word_start - 1] not in " \t":
|
||||
return None
|
||||
marker = "__nx_tcloo_completion_cursor__"
|
||||
lines = list(source_lines)
|
||||
suffix = lines[position.line][len(prefix):]
|
||||
remaining = re.match(r"[\w:]*", suffix).group()
|
||||
lines[position.line] = prefix + marker + suffix[len(remaining):]
|
||||
tree = parse_completion_source("\n".join(lines))
|
||||
if tree is None:
|
||||
return None
|
||||
classes, result, _ = _analyze(tree, typed, marker)
|
||||
if result is None:
|
||||
return None
|
||||
cls, internal = result
|
||||
methods = classes[cls].methods if cls else {"new": ("args", None), "create": ("name args", None)}
|
||||
methods = dict(methods)
|
||||
if cls:
|
||||
methods.setdefault("destroy", ("", None))
|
||||
suffix = source_lines[position.line][len(prefix):]
|
||||
remaining = re.match(r"[\w:]*", suffix).group()
|
||||
start = position.character - len(typed.encode("utf-16-le")) // 2
|
||||
end = position.character + len(remaining.encode("utf-16-le")) // 2
|
||||
return [lsp.CompletionItem(
|
||||
label=name, kind=lsp.CompletionItemKind.Method,
|
||||
detail=f"{cls or 'class'} {name} {signature}".rstrip(),
|
||||
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=name),
|
||||
) for name, (signature, _) in sorted(methods.items())
|
||||
if name.startswith(typed) and (internal or not name.startswith("_") and not name[:1].isupper())]
|
||||
|
||||
|
||||
def _analyze(tree, typed="", marker=""):
|
||||
classes = {}
|
||||
contexts = []
|
||||
calls = []
|
||||
|
||||
def collect(script, namespace=""):
|
||||
if script is None:
|
||||
return
|
||||
for cmd in script.children:
|
||||
if not isinstance(cmd, Command):
|
||||
continue
|
||||
args = cmd.args
|
||||
routine = (cmd.routine.contents or "").removeprefix("::")
|
||||
if routine == "namespace" and len(args) == 3 and args[0].contents == "eval" and args[1].contents:
|
||||
collect(_body(args[2]), _qualified(args[1].contents, namespace))
|
||||
elif routine == "oo::class" and len(args) == 3 and args[0].contents == "create" and args[1].contents:
|
||||
name = _qualified(args[1].contents, namespace)
|
||||
info = classes.setdefault(name, ClassInfo(namespace=namespace))
|
||||
body = _body(args[2])
|
||||
if body is None:
|
||||
continue
|
||||
for method in body.children:
|
||||
if not isinstance(method, Command):
|
||||
continue
|
||||
ma = method.args
|
||||
if method.routine.contents == "method" and len(ma) == 3 and ma[0].contents:
|
||||
method_body = _body(ma[2])
|
||||
info.methods[ma[0].contents] = (ma[1].contents or "", method_body)
|
||||
contexts.append((method_body, namespace, name))
|
||||
elif method.routine.contents in {"constructor", "destructor"} and ma:
|
||||
if method.routine.contents == "constructor" and len(ma) == 2:
|
||||
info.constructor = ma[0].contents or ""
|
||||
contexts.append((_body(ma[-1]), namespace, name))
|
||||
|
||||
collect(tree)
|
||||
result = None
|
||||
|
||||
def receiver(node, env, objects, namespace, owner, depth=0):
|
||||
if depth > 12:
|
||||
return None
|
||||
if isinstance(node, VarSub):
|
||||
return env.get(node.value)
|
||||
if isinstance(node, CommandSub) and len(node.children) == 1:
|
||||
return returned(node.children[0], env, objects, namespace, owner, depth + 1)
|
||||
name = node.contents
|
||||
return objects.get(_qualified(name, namespace)) if name else None
|
||||
|
||||
def returned(cmd, env, objects, namespace, owner, depth=0):
|
||||
if not isinstance(cmd, Command) or depth > 12:
|
||||
return None
|
||||
args = cmd.args
|
||||
name = cmd.routine.contents
|
||||
if name == "self" and not args:
|
||||
return owner
|
||||
qualified = _qualified(name, namespace) if name else None
|
||||
if qualified in classes and args and args[0].contents in {"new", "create"}:
|
||||
return qualified
|
||||
cls = owner if name == "my" else receiver(cmd.routine, env, objects, namespace, owner, depth + 1)
|
||||
if cls not in classes or not args:
|
||||
return None
|
||||
method = classes[cls].methods.get(args[0].contents)
|
||||
if method is None or method[1] is None:
|
||||
return None
|
||||
# Only infer unconditional final returns; conditional results stay unknown.
|
||||
commands = [c for c in method[1].children if isinstance(c, Command)]
|
||||
if commands and commands[-1].routine.contents == "return" and len(commands[-1].args) == 1:
|
||||
return receiver(commands[-1].args[0], {}, objects, classes[cls].namespace, cls, depth + 1)
|
||||
return None
|
||||
|
||||
def walk(script, env, objects, namespace="", owner=None):
|
||||
nonlocal result
|
||||
if script is None:
|
||||
return
|
||||
for cmd in script.children:
|
||||
if not isinstance(cmd, Command):
|
||||
continue
|
||||
args = cmd.args
|
||||
name = cmd.routine.contents
|
||||
cls = owner if name == "my" else receiver(cmd.routine, env, objects, namespace, owner)
|
||||
method_name = args[0].contents if args else None
|
||||
if cls in classes and method_name in classes[cls].methods:
|
||||
calls.append(MethodCall(cmd, f"{cls} {method_name}", classes[cls].methods[method_name][0]))
|
||||
elif name and _qualified(name, namespace) in classes and method_name in {"new", "create"}:
|
||||
cls = _qualified(name, namespace)
|
||||
parameters = classes[cls].constructor
|
||||
if method_name == "create":
|
||||
parameters = "objectName " + parameters
|
||||
calls.append(MethodCall(cmd, f"{cls} {method_name}", parameters))
|
||||
if marker and args and args[0].contents == typed + marker:
|
||||
cls = owner if name == "my" else receiver(cmd.routine, env, objects, namespace, owner)
|
||||
if cls in classes:
|
||||
result = (cls, name == "my")
|
||||
elif name and _qualified(name, namespace) in classes:
|
||||
result = (None, False)
|
||||
return
|
||||
# Command substitutions can contain the completion receiver.
|
||||
for node in cmd.children:
|
||||
if isinstance(node, CommandSub):
|
||||
walk(node, env, objects, namespace, owner)
|
||||
if name == "set" and len(args) == 2 and args[0].contents:
|
||||
env[args[0].contents] = receiver(args[1], env, objects, namespace, owner)
|
||||
elif name == "unset":
|
||||
for arg in args:
|
||||
env.pop(arg.contents, None)
|
||||
elif name == "proc" and len(args) == 3:
|
||||
walk(_body(args[2]), {}, objects.copy(), namespace)
|
||||
elif name == "namespace" and len(args) == 3 and args[0].contents == "eval" and args[1].contents:
|
||||
walk(_body(args[2]), {}, objects, _qualified(args[1].contents, namespace))
|
||||
elif name and _qualified(name, namespace) in classes and len(args) >= 2 and args[0].contents == "create" and args[1].contents:
|
||||
objects[_qualified(args[1].contents, namespace)] = _qualified(name, namespace)
|
||||
else:
|
||||
for arg in args:
|
||||
if isinstance(arg, Script):
|
||||
# Branch-local facts are not propagated beyond the branch.
|
||||
walk(arg, env.copy(), objects.copy(), namespace, owner)
|
||||
|
||||
walk(tree, {}, {})
|
||||
for body, namespace, owner in contexts:
|
||||
walk(body, {}, {}, namespace, owner)
|
||||
return classes, result, calls
|
||||
|
||||
|
||||
def resolved_method_calls(source):
|
||||
tree = parse_completion_source(source)
|
||||
return _analyze(tree)[2] if tree is not None else []
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Class declarations and references shared by completion and highlighting."""
|
||||
|
||||
import lsprotocol.types as lsp
|
||||
from tclint.syntax_tree import Command
|
||||
|
||||
from tools.tcloo_completion import _body, _qualified
|
||||
|
||||
|
||||
def class_symbols(tree):
|
||||
"""Return qualified class declarations and statically resolved name nodes."""
|
||||
declarations = {}
|
||||
commands = []
|
||||
|
||||
def walk(node, namespace="", in_class=False):
|
||||
if node is None:
|
||||
return
|
||||
if isinstance(node, Command):
|
||||
args = node.args
|
||||
name = (node.routine.contents or "").removeprefix("::")
|
||||
commands.append((node, namespace))
|
||||
if (name == "namespace" and len(args) == 3
|
||||
and args[0].contents == "eval" and args[1].contents):
|
||||
walk(_body(args[2]), _qualified(args[1].contents, namespace), in_class)
|
||||
return
|
||||
if (name == "oo::class" and len(args) == 3
|
||||
and args[0].contents == "create" and args[1].contents):
|
||||
declarations[_qualified(args[1].contents, namespace)] = args[1]
|
||||
walk(_body(args[2]), namespace, True)
|
||||
return
|
||||
if in_class and name in {"method", "constructor", "destructor"} and args:
|
||||
walk(_body(args[-1]), namespace, True)
|
||||
return
|
||||
for child in node.children:
|
||||
walk(child, namespace, in_class)
|
||||
|
||||
walk(tree)
|
||||
references = []
|
||||
for command, namespace in commands:
|
||||
name = command.routine.contents
|
||||
if name and any(candidate in declarations for candidate in (
|
||||
_qualified(name, namespace), _qualified(name, ""),
|
||||
)):
|
||||
references.append(command.routine)
|
||||
return declarations, references
|
||||
|
||||
|
||||
def class_completion_items(tree):
|
||||
declarations, _ = class_symbols(tree)
|
||||
return [lsp.CompletionItem(
|
||||
label=name.removeprefix("::"),
|
||||
kind=lsp.CompletionItemKind.Class,
|
||||
detail=f"TclOO class {name}",
|
||||
) for name in sorted(declarations)]
|
||||
Reference in New Issue
Block a user