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
+79
View File
@@ -2,6 +2,7 @@ import logging
import os
import pathlib
import threading
import xml.etree.ElementTree as ET
from typing import List, Optional, Tuple
import lsprotocol.types as lsp
@@ -15,6 +16,8 @@ from tclint.violations import Violation
from tools import checks, parser
from tools.completion_items import CompletionCollector
from tools.tcloo_symbols import class_completion_items
from tools.tcloo_completion import indexed_classes
from tools.file_sourcing import get_all_psc_files, psc_script_files
from tools.formatter import NxFormatter as Formatter
from tools.inlay_hint import InlayHintSignature, build_custom_inlay_signatures
from tools.navigation import FileSymbolIndex, build_file_symbol_index
@@ -35,6 +38,10 @@ class TclLanguageServer(LanguageServer):
self.poco_completion: dict = {}
self.proc_signatures: dict = {}
self.proc_docs: dict = {}
self.class_indexes: dict = {}
self.psc_script_paths: list[str] = []
self._psc_files: dict[str, list[pathlib.Path]] = {}
self._psc_lock = threading.RLock()
self.navigation_indexes: dict[str, FileSymbolIndex] = {}
self.variable_indexes: dict[
str,
@@ -171,6 +178,74 @@ class TclLanguageServer(LanguageServer):
self._workspace_completion_cache = (self._index_generation, items)
return items
def class_snapshot(self, current_path) -> dict:
"""Share class metadata, without stale definitions from the active file.
PSC scripts override other workspace files in their listed load order.
The request parser adds the current document's declarations last.
"""
with self._index_lock:
indexes = {self._normalized_path(path): classes for path, classes in self.class_indexes.items()}
paths = sorted(self.class_indexes, key=str.casefold)
psc_paths = {self._normalized_path(path) for path in self.psc_script_paths}
paths = [path for path in paths if self._normalized_path(path) not in psc_paths]
paths.extend(self.psc_script_paths)
classes = {}
for path in paths:
if not self.paths_equal(path, current_path):
classes.update(indexes.get(self._normalized_path(path), {}))
return classes
def refresh_psc_scripts(self, roots, report=LOGGER.warning):
"""Index PSC dependencies through the same pipeline as workspace procs."""
with self._psc_lock:
discovered = {}
for root in roots:
for psc in get_all_psc_files(root):
try:
discovered[str(psc)] = psc_script_files(psc)
except (OSError, ET.ParseError) as error:
report(f"Could not read PSC {psc}: {error}")
discovered[str(psc)] = self._psc_files.get(str(psc), [])
paths = [str(path) for scripts in discovered.values() for path in scripts]
with self._index_lock:
previous = set(self.psc_script_paths)
self.psc_script_paths = paths
self._psc_files = discovered
try:
open_documents = {
self._normalized_path(document.path): document
for document in self.workspace.text_documents.values()
}
except RuntimeError:
open_documents = {}
for removed in previous - set(paths):
path = pathlib.Path(removed)
if (not any(self._is_same_or_child(path, root) for root in roots)
and self._normalized_path(path) not in open_documents):
self.remove_file_state(path.as_uri())
for path_string in dict.fromkeys(paths):
path = pathlib.Path(path_string)
uri = path.as_uri()
document = open_documents.get(self._normalized_path(path))
if document is None and not path.is_file():
self.remove_file_state(uri)
report(f"PSC script not found: {path}")
continue
try:
if document is None:
data = path.read_bytes()
try:
source = data.decode("utf-8-sig")
except UnicodeDecodeError:
# Older Windows NX layers use the ANSI code page.
source = data.decode("cp1252")
document = TextDocument(uri=uri, source=source, language_id="tcl")
if not self.update_poco_completion_for_file(document, cache_tree=False):
report(f"Could not index PSC script: {path}")
except (OSError, UnicodeError) as error:
report(f"Could not read PSC script {path}: {error}")
def completion_items_by_file_snapshot(
self,
) -> dict[str, tuple[lsp.CompletionItem, ...]]:
@@ -455,6 +530,7 @@ class TclLanguageServer(LanguageServer):
self.poco_completion.pop(filepath, None)
self.proc_signatures.pop(filepath, None)
self.proc_docs.pop(filepath, None)
self.class_indexes.pop(filepath, None)
self.navigation_indexes.pop(filepath, None)
self.variable_indexes.pop(filepath, None)
self._committed_index_versions.pop(filepath, None)
@@ -486,6 +562,7 @@ class TclLanguageServer(LanguageServer):
self.poco_completion,
self.proc_signatures,
self.proc_docs,
self.class_indexes,
self.navigation_indexes,
self.variable_indexes,
self._index_tokens,
@@ -545,6 +622,7 @@ class TclLanguageServer(LanguageServer):
)
tree.accept(collector, recurse=True)
collector.custom_functions.extend(class_completion_items(tree))
classes = indexed_classes(tree, document.uri, document.source)
docs = build_proc_docs(tree, document.source)
navigation_index = build_file_symbol_index(
filepath, document.uri, tree
@@ -565,6 +643,7 @@ class TclLanguageServer(LanguageServer):
self.poco_completion[filepath] = list(collector.custom_functions)
self.proc_signatures[filepath] = dict(collector.proc_signatures)
self.proc_docs[filepath] = docs
self.class_indexes[filepath] = classes
self.navigation_indexes[filepath] = navigation_index
self.variable_indexes[filepath] = (document.version, variable_index)
self._committed_index_versions[filepath] = document.version