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:
@@ -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 []
|
||||
Reference in New Issue
Block a user