Introduce several changes to reduce full-document reparses, lock contention and redundant work when handling TclOO analysis and navigation: - Add a cheap may_contain_classes pre-check and several cursor/receiver heuristics so completions, signature help and tcloo definitions skip the expensive marker reparse when the document cannot contain useful OO info. - Allow passing an existing parsed tree into tcloo completion/signature/definition helpers; update callers to use the server's cached tree when available. - Use a thread-local parser for request-time parse_source to avoid blocking the shared parser during background indexing, and add navigation_state() which returns cached definition identities (invalidated on index generation changes). - Add a cheap name pre-filter (_may_resolve_to) for symbol matching and only run class highlighting when classes may exist. These changes reduce contention and repeated parsing, improve responsiveness for requests during background indexing, and cache navigation definition identities. Tests were added/updated to assert caching and non-blocking behavior.
334 lines
15 KiB
Python
334 lines
15 KiB
Python
"""Static TclOO inference using local and indexed classes, without executing Tcl."""
|
|
|
|
from collections.abc import Callable, 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.signature_help import _active_argument, _contains_cursor
|
|
from tools.tcl_command_completion import line_prefix_at_position
|
|
|
|
_LEADING_RECEIVER = re.compile(r"\s*([A-Za-z_]\w*)\s+[\w:]*$")
|
|
|
|
|
|
def may_contain_classes(source, external_classes=None) -> bool:
|
|
"""Cheap pre-check: without any class, TclOO analysis yields nothing."""
|
|
return bool(external_classes) or "oo::class" in source
|
|
|
|
|
|
def _may_be_receiver(name, source_lines, external_classes) -> bool:
|
|
"""Whether a bare command word can name a class, an object or `my`.
|
|
|
|
Objects and local classes only come from `... create <name>`, so a word
|
|
never created anywhere cannot resolve and needs no full-document parse.
|
|
"""
|
|
if name in {"my", "self", "next"}:
|
|
return True
|
|
if any(key.rsplit("::", 1)[-1] == name for key in external_classes or ()):
|
|
return True
|
|
created = re.compile(rf"\bcreate\s+[{{\"]?(?:[\w:]*::)?{re.escape(name)}\b")
|
|
return any("create" in line and created.search(line) for line in source_lines)
|
|
|
|
|
|
@dataclass
|
|
class ClassInfo:
|
|
methods: dict[str, tuple[str, Script | None]] = field(default_factory=dict)
|
|
namespace: str = ""
|
|
constructor: str = ""
|
|
definition: lsp.Location | None = None
|
|
method_definitions: dict[str, lsp.Location] = field(default_factory=dict)
|
|
constructor_definition: lsp.Location | None = None
|
|
|
|
|
|
@dataclass
|
|
class MethodCall:
|
|
command: Command
|
|
label: str
|
|
parameters: str
|
|
argument_offset: int = 1
|
|
definition: lsp.Location | None = None
|
|
|
|
|
|
def name_location(node, uri, source):
|
|
"""Locate the literal name, excluding braces/quotes, using UTF-16 columns."""
|
|
if not uri or node.contents is None or node.contents_pos is None:
|
|
return None
|
|
line, column = node.contents_pos
|
|
lines = source.splitlines()
|
|
prefix = lines[line - 1][:column - 1] if line <= len(lines) else ""
|
|
start = len(prefix.encode("utf-16-le")) // 2
|
|
end = start + len(node.contents.encode("utf-16-le")) // 2
|
|
return lsp.Location(uri=uri, range=lsp.Range(
|
|
start=lsp.Position(line=line - 1, character=start),
|
|
end=lsp.Position(line=line - 1, character=end)))
|
|
|
|
|
|
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 _cursor_may_be_method_word(tree, source_lines, position) -> bool:
|
|
"""Whether the innermost command at the cursor is at its first argument.
|
|
|
|
The marker parse below can only succeed there, and the current document's
|
|
tree has the same structure apart from the marker.
|
|
"""
|
|
line = source_lines[position.line]
|
|
# AST columns are codepoints; LSP columns are UTF-16 code units.
|
|
prefix = line.encode("utf-16-le")[:position.character * 2].decode("utf-16-le", errors="ignore")
|
|
cursor = (position.line, len(prefix))
|
|
lines = list(source_lines)
|
|
innermost = None
|
|
|
|
def walk(node):
|
|
nonlocal innermost
|
|
start, end = getattr(node, "pos", None), getattr(node, "end_pos", None)
|
|
if start is not None and end is not None and not start[0] - 1 <= cursor[0] <= end[0] - 1:
|
|
return
|
|
if isinstance(node, Command) and _contains_cursor(node, lines, cursor):
|
|
innermost = node
|
|
for child in node.children:
|
|
walk(child)
|
|
|
|
walk(tree)
|
|
return innermost is None or _active_argument(innermost, cursor) == 0
|
|
|
|
|
|
def tcloo_completions(
|
|
source_lines: Sequence[str], position: lsp.Position, external_classes=None,
|
|
current_tree: Callable[[], Script | None] | None = None,
|
|
) -> list[lsp.CompletionItem] | None:
|
|
"""Return receiver-specific methods, or None outside a known OO context.
|
|
|
|
`current_tree` lazily returns the parsed, unmodified document (or None) so
|
|
cursors that cannot hold a method name skip the full marker reparse.
|
|
"""
|
|
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
|
|
if not external_classes and not any("oo::class" in line for line in source_lines):
|
|
return None
|
|
continued = position.line > 0 and source_lines[position.line - 1].endswith("\\")
|
|
if not continued:
|
|
# The first word is the command itself, never a method name.
|
|
if not prefix[:word_start].strip():
|
|
return None
|
|
receiver = _LEADING_RECEIVER.match(prefix)
|
|
if receiver is not None and not _may_be_receiver(receiver.group(1), source_lines, external_classes):
|
|
return None
|
|
tree = current_tree() if current_tree is not None else None
|
|
if tree is not None and not _cursor_may_be_method_word(tree, source_lines, position):
|
|
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, external_classes)
|
|
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 _collect_classes(tree, classes, uri=None, source=""):
|
|
contexts = []
|
|
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 = ClassInfo(namespace=namespace)
|
|
info.definition = name_location(args[1], uri, source)
|
|
classes[name] = info
|
|
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)
|
|
location = name_location(ma[0], uri, source)
|
|
if location is not None:
|
|
info.method_definitions[ma[0].contents] = location
|
|
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 ""
|
|
info.constructor_definition = name_location(method.routine, uri, source)
|
|
contexts.append((_body(ma[-1]), namespace, name))
|
|
|
|
collect(tree)
|
|
return contexts
|
|
|
|
|
|
def indexed_classes(tree, uri=None, source=""):
|
|
classes = {}
|
|
_collect_classes(tree, classes, uri, source)
|
|
return classes
|
|
|
|
|
|
def _analyze(tree, typed="", marker="", external_classes=None, uri=None, source=""):
|
|
classes = dict(external_classes or {})
|
|
calls = []
|
|
|
|
contexts = _collect_classes(tree, classes, uri, source)
|
|
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],
|
|
definition=classes[cls].method_definitions.get(method_name)))
|
|
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,
|
|
definition=classes[cls].constructor_definition or classes[cls].definition))
|
|
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, external_classes=None, tree=None):
|
|
if not may_contain_classes(source, external_classes):
|
|
return []
|
|
if tree is None:
|
|
tree = parse_completion_source(source)
|
|
return _analyze(tree, external_classes=external_classes)[2] if tree is not None else []
|