feat(server): index PSC scripts and provide cross-file TclOO navigation/completions
Add PSC (.psc) indexing and share TclOO class metadata across files so class definitions discovered via PSC layers can be used for completions, signature help, inlay hints, and "go to definition". Key behavior changes: - Client file watcher now includes *.psc and .vscode launch paths/tests updated to use the postprocessor test folder; .gitignore updated to ignore that folder. - Server watches .psc changes and refreshes a PSC script index; new tools/tcloo_navigation.py exposes tcloo_definition used by the language server to resolve cross-file class/constructor/method definitions. - Language server uses class_snapshot(document.path) when producing TclOO completions, signature help, and inlay hints so resolved class metadata is available across files. Also includes related docs/changelog updates, minor code formatting cleanups, and added tests for PSC/TclOO behavior.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Conservative, document-local TclOO type inference without executing Tcl."""
|
||||
"""Static TclOO inference using local and indexed classes, without executing Tcl."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
@@ -17,6 +17,9 @@ 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
|
||||
@@ -25,6 +28,21 @@ class MethodCall:
|
||||
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):
|
||||
@@ -57,7 +75,7 @@ def _qualified(name, namespace):
|
||||
|
||||
|
||||
def tcloo_completions(
|
||||
source_lines: Sequence[str], position: lsp.Position,
|
||||
source_lines: Sequence[str], position: lsp.Position, external_classes=None,
|
||||
) -> list[lsp.CompletionItem] | None:
|
||||
"""Return receiver-specific methods, or None outside a known OO context."""
|
||||
prefix = line_prefix_at_position(source_lines, position)
|
||||
@@ -77,7 +95,7 @@ def tcloo_completions(
|
||||
tree = parse_completion_source("\n".join(lines))
|
||||
if tree is None:
|
||||
return None
|
||||
classes, result, _ = _analyze(tree, typed, marker)
|
||||
classes, result, _ = _analyze(tree, typed, marker, external_classes)
|
||||
if result is None:
|
||||
return None
|
||||
cls, internal = result
|
||||
@@ -99,11 +117,8 @@ def tcloo_completions(
|
||||
if name.startswith(typed) and (internal or not name.startswith("_") and not name[:1].isupper())]
|
||||
|
||||
|
||||
def _analyze(tree, typed="", marker=""):
|
||||
classes = {}
|
||||
def _collect_classes(tree, classes, uri=None, source=""):
|
||||
contexts = []
|
||||
calls = []
|
||||
|
||||
def collect(script, namespace=""):
|
||||
if script is None:
|
||||
return
|
||||
@@ -116,7 +131,9 @@ def _analyze(tree, typed="", marker=""):
|
||||
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))
|
||||
info = ClassInfo(namespace=namespace)
|
||||
info.definition = name_location(args[1], uri, source)
|
||||
classes[name] = info
|
||||
body = _body(args[2])
|
||||
if body is None:
|
||||
continue
|
||||
@@ -127,13 +144,31 @@ def _analyze(tree, typed="", marker=""):
|
||||
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):
|
||||
@@ -180,13 +215,15 @@ def _analyze(tree, typed="", marker=""):
|
||||
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]))
|
||||
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))
|
||||
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:
|
||||
@@ -221,6 +258,6 @@ def _analyze(tree, typed="", marker=""):
|
||||
return classes, result, calls
|
||||
|
||||
|
||||
def resolved_method_calls(source):
|
||||
def resolved_method_calls(source, external_classes=None):
|
||||
tree = parse_completion_source(source)
|
||||
return _analyze(tree)[2] if tree is not None else []
|
||||
return _analyze(tree, external_classes=external_classes)[2] if tree is not None else []
|
||||
|
||||
Reference in New Issue
Block a user