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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user