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:
Christoph Brandau
2026-09-21 20:47:51 +02:00
parent f88a50d4ab
commit 757b885f28
21 changed files with 699 additions and 378 deletions
+22 -10
View File
@@ -1,4 +1,5 @@
import xml.etree.ElementTree as ET
import os
from dataclasses import dataclass
from typing import List, Optional
from pathlib import Path
@@ -14,6 +15,9 @@ class SourcedFile:
def read_psc_file(psc_file: Path) -> List[SourcedFile]:
tree = ET.parse(psc_file)
root = tree.getroot()
# PSC exports may use a default XML namespace.
for element in root.iter():
element.tag = element.tag.rsplit("}", 1)[-1]
layers = root.findall(".//Layer")
@@ -37,15 +41,23 @@ def read_psc_file(psc_file: Path) -> List[SourcedFile]:
def get_all_psc_files(root_path: Path) -> list[Path]:
return [path for path in root_path.rglob("*.psc")]
return sorted(root_path.rglob("*.psc"), key=lambda path: str(path).casefold())
if __name__ == "__main__":
test = get_all_psc_files(
Path(
r"H:\janus-engineering-customers\KSB_Frankenthal\custom\library\machine\installed_machines\ksb_pe_grob_g550_sone\postprocessor"
)
)
print(test)
for pp in test:
read_psc_file(pp)
def psc_script_files(psc_file: Path) -> list[Path]:
"""Resolve layer script paths relative to the PSC, preserving load order."""
def expanded(value):
return Path(os.path.expandvars(value).replace("\\", "/"))
paths = []
for layer in read_psc_file(psc_file):
folder = layer.subfolder or "."
base = psc_file.parent / expanded(os.environ.get(folder, folder))
for name in layer.files:
filename = expanded(name)
if not filename.suffix:
filename = filename.with_suffix(".tcl")
path = (base / filename).resolve()
if path.suffix.lower() == ".tcl":
paths.append(path)
return paths
+3 -1
View File
@@ -158,11 +158,13 @@ class InlayHintGenerator(Visitor):
requested_range: lsp.Range | None = None,
parameter_names: str = "all",
suppress_when_argument_matches_name: bool = True,
external_classes=None,
):
self.source_lines = (
source_lines if source_lines is not None else source.splitlines()
)
self.source = source
self.external_classes = external_classes
self.proc_signatures = proc_signatures
self.requested_range = requested_range
self.parameter_names = parameter_names
@@ -198,7 +200,7 @@ class InlayHintGenerator(Visitor):
walk(tree)
if self.parameter_names != "none":
for call in resolved_method_calls(self.source):
for call in resolved_method_calls(self.source, self.external_classes):
if not self._node_intersects_requested_range(call.command):
continue
parameters = method_parameters(call.parameters)
+35 -11
View File
@@ -7,6 +7,7 @@ from tclint.commands.plugins import PluginManager
from tclint.syntax_tree import BareWord, Command, QuotedWord, Visitor
from tools.variable_names import variable_name
from tools.tcloo_symbols import class_symbols
from tools.tcloo_completion import _analyze
# Constructing a PluginManager scans entry points, and get_commands() rebuilds
# the builtin command set on every call. Semantic tokens are requested often, so
@@ -72,12 +73,9 @@ class _Highlighter(Visitor):
self._commands = _load_commands(plugins)
self._tokens = []
self._class_tokens = {}
self._method_tokens = {}
if isinstance(custom_functions, dict):
self._custom_function_names = frozenset(
item.label
for items in custom_functions.values()
for item in items
)
self._custom_function_names = frozenset(item.label for items in custom_functions.values() for item in items)
else:
self._custom_function_names = frozenset(custom_functions)
@@ -86,15 +84,18 @@ class _Highlighter(Visitor):
return
self._tokens.append((position, length, tok_type, modifiers or []))
def highlight_classes(self, tree):
declarations, references = class_symbols(tree)
def highlight_classes(self, tree, external_classes=None):
declarations, references = class_symbols(tree, external_classes)
for node, modifiers in [
*((node, [TokenModifier.declaration]) for node in declarations.values()),
*((node, []) for node in references),
]:
line, col = node.contents_pos
self._class_tokens[(line - 1, col - 1)] = (
(line - 1, col - 1), len(node.contents), "class", modifiers,
(line - 1, col - 1),
len(node.contents),
"class",
modifiers,
)
def _get_token_info(self, node):
@@ -124,6 +125,28 @@ class _Highlighter(Visitor):
return None
def highlight_methods(self, tree, source, uri, external_classes=None):
"""Use the same function token as procs for resolved TclOO methods."""
classes, _, calls = _analyze(tree, external_classes=external_classes, uri=uri, source=source)
lines = source.splitlines()
for info in classes.values():
for location in info.method_definitions.values():
if location.uri != uri:
continue
start, end = location.range.start, location.range.end
encoded = lines[start.line].encode("utf-16-le")
column = len(encoded[:start.character * 2].decode("utf-16-le"))
length = len(encoded[start.character * 2:end.character * 2].decode("utf-16-le"))
position = (start.line, column)
self._method_tokens[position] = (position, length, "function", [TokenModifier.declaration])
for call in calls:
node = call.command.args[0]
if node.contents is None or node.contents_pos is None:
continue
line, column = node.contents_pos
position = (line - 1, column - 1)
self._method_tokens[position] = (position, len(node.contents), "function", [])
def visit_quoted_word(self, word: QuotedWord):
if not word.contents:
return
@@ -170,7 +193,7 @@ class _Highlighter(Visitor):
if routine.contents == "puts":
line, col = routine.contents_pos
self._append_token((line - 1, col - 1), len(routine.contents), "function", [TokenModifier.builtin])
if routine.contents == "set" and command.args:
if routine.contents in ["set", "append", "lappend"] and command.args:
first_arg = command.args[0]
token_info = self._get_token_info(first_arg)
if first_arg.contents is None:
@@ -217,8 +240,9 @@ class _Highlighter(Visitor):
tokens = []
last_line = 0
last_col = 0
raw_tokens = [token for token in self._tokens if token[0] not in self._class_tokens]
raw_tokens.extend(self._class_tokens.values())
overrides = {**self._method_tokens, **self._class_tokens}
raw_tokens = [token for token in self._tokens if token[0] not in overrides]
raw_tokens.extend(overrides.values())
for (line, col), length, tok_type, tok_modifier in sorted(raw_tokens, key=lambda x: x[0]):
line_delta = line - last_line
col_delta = col
+44 -136
View File
@@ -284,6 +284,7 @@ def _options(*labels: str) -> tuple[OptionSpec, ...]:
OPTIONS_BY_PATH: dict[tuple[str, ...], tuple[OptionSpec, ...]] = {
("unset",): _options("nocomplain"),
("binary", "decode", "base64"): (OptionSpec("-strict"),),
("binary", "encode", "base64"): (
OptionSpec("-maxlen", takes_value=True),
@@ -437,109 +438,51 @@ _REPEATED_SUBCOMMAND_ARGUMENTS = frozenset(range(2, 33))
DYNAMIC_COMPLETION_RULES = (
# Variable-taking commands.
DynamicCompletionRule(("append",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(
("array", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "get"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "names"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "size"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "statistics"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("catch",), frozenset({2, 3}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "append"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "incr"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "lappend"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "update"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "with"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("global",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(("array", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "get"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "names"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "size"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "statistics"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("catch",), frozenset({2, 3}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "append"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "incr"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "lappend"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "update"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "with"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("global",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("incr",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(
("info", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(("info", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("lappend",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("set",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(
("unset",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("variable",), frozenset({1}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(("unset",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("variable",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("vwait",), frozenset({1}), DynamicCompletionKind.VARIABLE),
# Procedure-taking commands.
DynamicCompletionRule(
("info", "args"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(
("info", "body"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(
("info", "default"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(
("namespace", "origin"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(("info", "args"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
DynamicCompletionRule(("info", "body"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
DynamicCompletionRule(("info", "default"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
DynamicCompletionRule(("namespace", "origin"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
DynamicCompletionRule(("rename",), frozenset({1}), DynamicCompletionKind.PROCEDURE),
# Namespace-taking commands.
DynamicCompletionRule(
("namespace", "children"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
DynamicCompletionRule(("namespace", "children"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
DynamicCompletionRule(
("namespace", "delete"),
_REPEATED_SUBCOMMAND_ARGUMENTS,
DynamicCompletionKind.NAMESPACE,
),
DynamicCompletionRule(
("namespace", "eval"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
DynamicCompletionRule(
("namespace", "exists"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
DynamicCompletionRule(
("namespace", "parent"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
DynamicCompletionRule(("namespace", "eval"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
DynamicCompletionRule(("namespace", "exists"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
DynamicCompletionRule(("namespace", "parent"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
# Path-taking commands. Source files are narrowed to Tcl while directories
# remain visible so users can continue navigating.
DynamicCompletionRule(("cd",), frozenset({1}), DynamicCompletionKind.PATH),
DynamicCompletionRule(
("load",), frozenset({1}), DynamicCompletionKind.PATH, (".dll", ".so", ".dylib")
),
DynamicCompletionRule(("load",), frozenset({1}), DynamicCompletionKind.PATH, (".dll", ".so", ".dylib")),
DynamicCompletionRule(("open",), frozenset({1}), DynamicCompletionKind.PATH),
DynamicCompletionRule(
("source",), frozenset({1, 3}), DynamicCompletionKind.PATH, (".tcl",)
),
DynamicCompletionRule(("source",), frozenset({1, 3}), DynamicCompletionKind.PATH, (".tcl",)),
*(
DynamicCompletionRule(
("file", subcommand),
@@ -666,12 +609,7 @@ SUBCOMMAND_SNIPPET_ITEMS = {
TCL_COMMAND_NAMES = tuple(
sorted(
{path[0] for path in SUBCOMMANDS_BY_PATH}
| {path[0] for path in OPTIONS_BY_PATH}
| set(TCL_COMMAND_SNIPPET_ITEMS)
| {rule.path[0] for rule in DYNAMIC_COMPLETION_RULES}
)
sorted({path[0] for path in SUBCOMMANDS_BY_PATH} | {path[0] for path in OPTIONS_BY_PATH} | set(TCL_COMMAND_SNIPPET_ITEMS) | {rule.path[0] for rule in DYNAMIC_COMPLETION_RULES})
)
TCL_COMMAND_ITEMS = tuple(
@@ -688,9 +626,7 @@ TCL_COMMAND_ITEMS = tuple(
)
def line_prefix_at_position(
source_lines: Sequence[str], position: Position
) -> str | None:
def line_prefix_at_position(source_lines: Sequence[str], position: Position) -> str | None:
"""Return the current line before an LSP UTF-16 position."""
if position.line < 0 or position.line >= len(source_lines):
@@ -703,9 +639,7 @@ def line_prefix_at_position(
return line[:codepoint_offset]
def tcl_argument_completion(
source_lines: Sequence[str], position: Position
) -> TclArgumentCompletion | None:
def tcl_argument_completion(source_lines: Sequence[str], position: Position) -> TclArgumentCompletion | None:
"""Describe static and dynamic argument completion at ``position``.
``None`` means that the cursor is not at a command-specific completion
@@ -771,7 +705,7 @@ def tcl_argument_completion(
active_prefix,
)
if option_completion is not None:
if active_prefix.startswith("-"):
if active_prefix.startswith("-") or (path == ("unset",) and active_index == 1 and not active_prefix):
return option_completion
return _merge_dynamic_completion(
(*argument_items, *option_completion.items),
@@ -780,9 +714,7 @@ def tcl_argument_completion(
)
if argument_items:
return _merge_dynamic_completion(
argument_items, dynamic_completion, active_prefix
)
return _merge_dynamic_completion(argument_items, dynamic_completion, active_prefix)
return dynamic_completion
@@ -827,9 +759,7 @@ def _option_completion(
if active_prefix and not active_prefix.startswith("-"):
return None
remaining_options = tuple(
option.label for option in options if option.label not in used_options
)
remaining_options = tuple(option.label for option in options if option.label not in used_options)
return TclArgumentCompletion(
items=_completion_items(
remaining_options,
@@ -840,16 +770,9 @@ def _option_completion(
)
def _dynamic_completion(
words: Sequence[str], active_index: int, active_prefix: str
) -> TclArgumentCompletion | None:
for rule in sorted(
DYNAMIC_COMPLETION_RULES, key=lambda item: len(item.path), reverse=True
):
if (
active_index in rule.argument_indices
and tuple(words[: len(rule.path)]) == rule.path
):
def _dynamic_completion(words: Sequence[str], active_index: int, active_prefix: str) -> TclArgumentCompletion | None:
for rule in sorted(DYNAMIC_COMPLETION_RULES, key=lambda item: len(item.path), reverse=True):
if active_index in rule.argument_indices and tuple(words[: len(rule.path)]) == rule.path:
return TclArgumentCompletion(
dynamic_kind=rule.kind,
active_prefix=active_prefix,
@@ -873,9 +796,7 @@ def _merge_dynamic_completion(
)
def _completion_items(
labels: Sequence[str], kind: CompletionItemKind, detail: str
) -> tuple[CompletionItem, ...]:
def _completion_items(labels: Sequence[str], kind: CompletionItemKind, detail: str) -> tuple[CompletionItem, ...]:
return tuple(
CompletionItem(
label=label,
@@ -913,9 +834,7 @@ def path_completion_items(
except (OSError, ValueError):
return ()
allowed_extensions = {
extension.casefold() for extension in completion.path_extensions
}
allowed_extensions = {extension.casefold() for extension in completion.path_extensions}
replace_start = max(
0,
position.character - len(raw_prefix.encode("utf-16-le")) // 2,
@@ -933,28 +852,17 @@ def path_completion_items(
is_directory = entry.is_dir()
except OSError:
continue
if (
not is_directory
and allowed_extensions
and entry.suffix.casefold() not in allowed_extensions
):
if not is_directory and allowed_extensions and entry.suffix.casefold() not in allowed_extensions:
continue
escaped_name = "".join(
f"\\{character}" if character.isspace() else character
for character in entry.name
)
escaped_name = "".join(f"\\{character}" if character.isspace() else character for character in entry.name)
new_text = f"{normalized_directory}{escaped_name}"
if is_directory:
new_text += "/"
items.append(
CompletionItem(
label=new_text,
kind=(
CompletionItemKind.Folder
if is_directory
else CompletionItemKind.File
),
kind=(CompletionItemKind.Folder if is_directory else CompletionItemKind.File),
detail="Directory" if is_directory else "File",
text_edit=TextEdit(range=replace_range, new_text=new_text),
)
+2 -2
View File
@@ -38,14 +38,14 @@ def method_parameters(parameters: str) -> list[MethodParameter]:
return []
def method_signature_help(source: str, position: lsp.Position) -> lsp.SignatureHelp | None:
def method_signature_help(source: str, position: lsp.Position, external_classes=None) -> lsp.SignatureHelp | None:
lines = source.split("\n")
if position.line >= len(lines):
return None
# AST columns are codepoints; LSP columns are UTF-16 code units.
prefix = lines[position.line].encode("utf-16-le")[:position.character * 2].decode("utf-16-le", errors="ignore")
cursor = (position.line, len(prefix))
candidates = [call for call in resolved_method_calls(source)
candidates = [call for call in resolved_method_calls(source, external_classes)
if _contains_cursor(call.command, lines, cursor)]
if not candidates:
return None
+49 -12
View File
@@ -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 []
+35
View File
@@ -0,0 +1,35 @@
"""Definition targets for literal TclOO classes and resolved method calls."""
from tools.tcloo_completion import _analyze, name_location, parse_completion_source
from tools.tcloo_symbols import class_symbols
def tcloo_definition(source, uri, position, external_classes=None):
tree = parse_completion_source(source)
if tree is None:
return None
classes, _, calls = _analyze(tree, external_classes=external_classes, uri=uri, source=source)
def contains(location):
if location is None:
return False
start, end = location.range.start, location.range.end
return (start.line, start.character) <= (position.line, position.character) < (end.line, end.character)
targets = {}
declarations, references = class_symbols(tree, classes, targets)
for node in references:
if contains(name_location(node, uri, source)):
return classes[targets[node.pos]].definition
for name, node in declarations.items():
if contains(name_location(node, uri, source)):
return classes[name].definition
for call in calls:
if call.command.args and contains(name_location(call.command.args[0], uri, source)):
return call.definition
# F12 on a declaration itself should stay on that declaration.
for info in classes.values():
for location in [*info.method_definitions.values(), info.constructor_definition]:
if location is not None and location.uri == uri and contains(location):
return location
return None
+7 -3
View File
@@ -6,7 +6,7 @@ from tclint.syntax_tree import Command
from tools.tcloo_completion import _body, _qualified
def class_symbols(tree):
def class_symbols(tree, external_classes=None, reference_targets=None):
"""Return qualified class declarations and statically resolved name nodes."""
declarations = {}
commands = []
@@ -34,13 +34,17 @@ def class_symbols(tree):
walk(child, namespace, in_class)
walk(tree)
known_classes = set(external_classes or ()) | declarations.keys()
references = []
for command, namespace in commands:
name = command.routine.contents
if name and any(candidate in declarations for candidate in (
target = next((candidate for candidate in (
_qualified(name, namespace), _qualified(name, ""),
)):
) if candidate in known_classes), None) if name else None
if target:
references.append(command.routine)
if reference_targets is not None:
reference_targets[command.routine.pos] = target
return declarations, references