Files
nx_post_support/server/src/tools/tcloo_completion.py
T
Christoph Brandau b2e6e9d250 perf(server): avoid unnecessary reparses and cache navigation definitions
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.
2026-09-23 12:04:36 +02:00

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 []