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
+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),
)