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:
Christoph Brandau
2026-09-11 23:38:42 +02:00
parent c28836933c
commit f88a50d4ab
12 changed files with 1007 additions and 42 deletions
+226
View File
@@ -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 []