"""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 `, 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 []