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
@@ -103,6 +103,26 @@ def _argument_completion_labels(source: str) -> set[str] | None:
return {item.label for item in completion.items}
def test_unset_space_shows_options_then_variables(tmp_path, monkeypatch):
server, document, _ = _completion_server(tmp_path, monkeypatch)
document = server.workspace.get_text_document(document.uri)
for version, tail in enumerate(["unset ", "unset -", "unset -nocomplain ", "unset -- ", "unset global"], start=2):
source = "set globalValue 1\n" + tail
document._source = source
document.version = version
items = _complete(document, lsp.Position(line=1, character=len(tail)))
labels = {item.label for item in items}
if tail in {"unset ", "unset -"}:
assert labels == {"-nocomplain", "--"}
else:
assert "globalValue" in labels
assert "-nocomplain" not in labels
if tail == "unset -nocomplain ":
assert "--" in labels
else:
assert "--" not in labels
def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch):
server, _, _ = _completion_server(tmp_path, monkeypatch)
workspace = _document(
@@ -0,0 +1,131 @@
from pathlib import Path
import lsprotocol.types as lsp
from pygls.workspace import Workspace
from pygls.workspace.text_document import TextDocument
import lsp_server
from lsp_tclserver import TclLanguageServer
from tools.file_sourcing import psc_script_files
from tools.tcloo_completion import tcloo_completions
from tools.semantic_tokens import TOKEN_TYPE_INDEX
CLASS = '''oo::class create MCS {
method initOrg {dx dy dz} {return [self]}
method toStr {{precision 7}} {}
}
proc helper {value} {}
'''
def setup_project(tmp_path, monkeypatch):
root = tmp_path / "project"
root.mkdir()
library = tmp_path / "external library"
library.mkdir()
script = library / "geometry.tcl"
script.write_text(CLASS, encoding="utf-8")
psc = root / "post.psc"
psc.write_text('''<Post><Layer Name="Geometry" SubFolder="../external library">
<Scripts><Filename Name="geometry" /></Scripts>
</Layer></Post>''', encoding="utf-8")
server = TclLanguageServer(name="psc-test", version="1")
server.protocol._workspace = Workspace(root_uri=root.as_uri(), sync_kind=lsp.TextDocumentSyncKind.Incremental,
workspace_folders=[], position_encoding=lsp.PositionEncodingKind.Utf16)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
return server, root, psc, script
def caller(server, root, source):
uri = (root / "caller.tcl").as_uri()
server.clear_cache_for_uri(uri)
server.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source))
return uri, lsp.Position(line=len(source.splitlines()) - 1, character=len(source.splitlines()[-1]))
def test_psc_external_class_available_in_all_language_features(tmp_path, monkeypatch):
server, root, _, script = setup_project(tmp_path, monkeypatch)
lsp_server._refresh_psc_index()
assert any(server.paths_equal(script, path) for path in server.class_indexes)
assert {"MCS", "helper"} <= {item.label for item in server.completion_items_snapshot()}
uri, position = caller(server, root, "set mcs [MCS new]\n$mcs ")
result = lsp_server.on_completion(lsp.CompletionParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=position))
assert {"initOrg", "toStr"} <= {item.label for item in result.items}
uri, position = caller(server, root, "set mcs [MCS new]\n$mcs initOrg 1 ")
result = lsp_server.signature_help(lsp.SignatureHelpParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=position))
assert result.signatures[0].label == "::MCS initOrg dx dy dz"
assert result.active_parameter == 1
uri, _ = caller(server, root, "set mcs [MCS new]\n$mcs initOrg 1 2 3")
monkeypatch.setattr(lsp_server, "_get_settings_by_document", lambda doc: {"inlayHint": True})
hints = lsp_server.inlay_hints(lsp.InlayHintParams(text_document=lsp.TextDocumentIdentifier(uri=uri), range=lsp.Range(
start=lsp.Position(line=0, character=0), end=lsp.Position(line=2, character=0))))
assert [hint.label[0].value for hint in hints] == ["dx:", "dy:", "dz:"]
tokens = lsp_server.semantic_tokens(lsp.SemanticTokensParams(text_document=lsp.TextDocumentIdentifier(uri=uri))).data
assert TOKEN_TYPE_INDEX["class"] in tokens[3::5]
uri, position = caller(server, root, "set mcs [MC")
result = lsp_server.on_completion(lsp.CompletionParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=position))
assert any(item.label == "MCS" and item.kind == lsp.CompletionItemKind.Class for item in result.items)
def test_psc_refresh_removes_unlinked_external_classes(tmp_path, monkeypatch):
server, root, psc, script = setup_project(tmp_path, monkeypatch)
server.refresh_psc_scripts([root])
psc.write_text("<Post/>", encoding="utf-8")
lsp_server.did_change_watched_files(lsp.DidChangeWatchedFilesParams(changes=[
lsp.FileEvent(uri=psc.as_uri(), type=lsp.FileChangeType.Changed)]))
assert "::MCS" not in server.class_snapshot(root / "caller.tcl")
assert not any(server.paths_equal(script, path) for path in server.class_indexes)
assert "MCS" not in {item.label for item in server.completion_items_snapshot()}
def test_class_metadata_updates_and_local_override_does_not_mutate_index(tmp_path, monkeypatch):
server, root, _, script = setup_project(tmp_path, monkeypatch)
server.refresh_psc_scripts([root])
classes = server.class_snapshot(root / "caller.tcl")
source = "oo::class create MCS {method local {} {}}\nset mcs [MCS new]\n$mcs "
items = tcloo_completions(source.splitlines(), lsp.Position(line=2, character=5), classes)
assert {item.label for item in items} == {"local", "destroy"}
assert "local" not in classes["::MCS"].methods
document = TextDocument(uri=script.as_uri(), source="oo::class create MCS {method changed {} {}}", version=2)
assert server.update_poco_completion_for_file(document)
assert set(server.class_snapshot(root / "caller.tcl")["::MCS"].methods) == {"changed"}
server.remove_file_state(script.as_uri())
assert "::MCS" not in server.class_snapshot(root / "caller.tcl")
def test_psc_load_order_missing_files_and_xml_namespace(tmp_path, monkeypatch):
server, root, psc, script = setup_project(tmp_path, monkeypatch)
override = root / "override.tcl"
override.write_text("oo::class create MCS {method override {} {}}", encoding="utf-8")
psc.write_text(f'''<Post xmlns="urn:psc">
<Layer Name="Base" SubFolder="..\\external library"><Scripts><Filename Name="geometry.tcl"/></Scripts></Layer>
<Layer Name="Custom"><Scripts><Filename Name="{override.as_posix()}"/><Filename Name="missing.tcl"/></Scripts></Layer>
</Post>''', encoding="utf-8")
assert psc_script_files(psc) == [script, override, root / "missing.tcl"]
messages = []
server.refresh_psc_scripts([root], messages.append)
assert set(server.class_snapshot(root / "caller.tcl")["::MCS"].methods) == {"override"}
assert any("missing.tcl" in message for message in messages)
def test_psc_refresh_preserves_unsaved_open_library(tmp_path, monkeypatch):
server, root, _, script = setup_project(tmp_path, monkeypatch)
server.workspace.put_text_document(lsp.TextDocumentItem(uri=script.as_uri(), language_id="tcl", version=3,
text="oo::class create MCS {method unsaved {} {}}"))
server.refresh_psc_scripts([root])
assert set(server.class_snapshot(root / "caller.tcl")["::MCS"].methods) == {"unsaved"}
def test_psc_environment_folder_and_legacy_encoding(tmp_path, monkeypatch):
server, root, psc, script = setup_project(tmp_path, monkeypatch)
monkeypatch.setenv("UGII_CAM_SHOP_DOC_DIR", str(script.parent))
psc.write_text('''<Post><Layer SubFolder="UGII_CAM_SHOP_DOC_DIR">
<Scripts><Filename Name="geometry"/></Scripts></Layer></Post>''', encoding="utf-8")
script.write_bytes(("# Ältere Bibliothek\n" + CLASS).encode("cp1252"))
server.refresh_psc_scripts([root])
assert "::MCS" in server.class_snapshot(root / "caller.tcl")
@@ -0,0 +1,56 @@
import lsprotocol.types as lsp
from tools.parser import CustomParser
from tools.semantic_tokens import _Highlighter, TOKEN_TYPE_INDEX, TokenModifier
def test_methods_use_proc_colors_without_coloring_plain_arguments():
source = '''oo::class create MCS {
method initOrg {x y z} {return [self]}
method reset {} {my initOrg 0 0 0}
}
set obj [MCS new]
$obj initOrg 1 2 3
puts initOrg
# initOrg
'''
tree = CustomParser().parse(source)
highlighter = _Highlighter([], {})
highlighter.highlight_classes(tree)
highlighter.highlight_methods(tree, source, "file:///test.tcl")
tree.accept(highlighter, recurse=True)
line = column = 0
tokens = []
for token in highlighter.tokens():
column = column + token.offset if token.line == 0 else token.offset
line += token.line
text = source.splitlines()[line][column:column + token.length]
tokens.append((line, column, text, token.tok_type, token.tok_modifiers))
methods = [token for token in tokens if token[2] in {"initOrg", "reset"}]
assert [token[2] for token in methods] == ["initOrg", "reset", "initOrg", "initOrg"]
assert all(token[3] == "function" for token in methods)
assert TokenModifier.declaration in methods[0][4]
assert TokenModifier.declaration in methods[1][4]
assert all(token[3] == "class" for token in tokens if token[2] == "MCS")
assert len({token[:2] for token in tokens}) == len(tokens)
def test_psc_method_calls_are_function_tokens(tmp_path, monkeypatch):
import lsp_server
from test_psc_classes import setup_project, caller
server, root, _, _ = setup_project(tmp_path, monkeypatch)
server.refresh_psc_scripts([root])
source = "set obj [MCS new]\n$obj initOrg 1 2 3"
uri, _ = caller(server, root, source)
data = lsp_server.semantic_tokens(lsp.SemanticTokensParams(
text_document=lsp.TextDocumentIdentifier(uri=uri))).data
line = column = 0
tokens = {}
for index in range(0, len(data), 5):
delta, offset, length, kind, _ = data[index:index + 5]
column = column + offset if delta == 0 else offset
line += delta
tokens[(line, source.splitlines()[line][column:column + length])] = kind
assert tokens[(1, "initOrg")] == TOKEN_TYPE_INDEX["function"]
assert tokens[(0, "MCS")] == TOKEN_TYPE_INDEX["class"]
@@ -0,0 +1,94 @@
import lsprotocol.types as lsp
import pytest
from pygls import uris
from tools.tcloo_navigation import tcloo_definition
CLASS = '''oo::class create MCS {
constructor {value} {}
method initOrg {dx dy dz} {return [self]}
method toStr {} {my initOrg 1 2 3}
}
'''
def locate(source, classes=None):
prefix, suffix = source.split("|")
return tcloo_definition(prefix + suffix, "file:///caller.tcl", lsp.Position(
line=prefix.count("\n"),
character=len(prefix.rsplit("\n", 1)[-1].encode("utf-16-le")) // 2), classes)
def target_text(location, source):
assert location is not None
span = location.range
line = source.splitlines()[span.start.line].encode("utf-16-le")
return line[span.start.character * 2:span.end.character * 2].decode("utf-16-le")
@pytest.mark.parametrize("code, expected", [
("set mcs [M|CS new 0]", "MCS"),
("set mcs [::M|CS new 0]", "MCS"),
("set mcs [MCS n|ew 0]", "constructor"),
("MCS cr|eate instance 0", "constructor"),
("set mcs [MCS new 0]\n$mcs init|Org 1 2 3", "initOrg"),
("MCS create instance 0\ninstance to|Str", "toStr"),
("set mcs [[MCS new 0] initOrg 1 2 3]\n$mcs to|Str", "toStr"),
("set mcs [MCS new 0]\nputs [$mcs init|Org", "initOrg"),
("puts 😀; set mcs [M|CS new 0]", "MCS"),
])
def test_local_class_method_and_constructor_targets(code, expected):
source = CLASS + code
target = locate(source)
assert target.uri == "file:///caller.tcl"
assert target_text(target, source.replace("|", "")) == expected
def test_my_and_method_declaration():
for source in [CLASS.replace("my initOrg", "my init|Org"), CLASS.replace("method initOrg", "method init|Org")]:
assert target_text(locate(source), CLASS) == "initOrg"
@pytest.mark.parametrize("code", [
"set mcs [MCS new 0]\nset mcs text\n$mcs init|Org 1 2 3",
"$unknown init|Org 1 2 3",
"puts {M|CS}",
"# M|CS",
"set mcs [MCS new 0]\n$mcs initOrg to|Str 2 3",
])
def test_no_guessing_for_unknown_receivers_or_plain_text(code):
assert locate(CLASS + code) is None
def test_same_method_name_resolves_to_correct_class():
source = CLASS + "oo::class create Other {method initOrg {} {}}\nset obj [Other new]\n$obj init|Org"
target = locate(source)
assert target.range.start.line == 5
def test_namespaced_class_and_utf16_definition():
source = 'namespace eval geo {\nputs 😀; ' + CLASS + '\nset obj [MCS new 0]\n$obj init|Org 1 2 3\n}'
assert target_text(locate(source), source.replace("|", "")) == "initOrg"
source = 'puts 😀; ' + CLASS + '\nset obj [M|CS new 0]'
target = locate(source)
assert target_text(target, source.replace("|", "")) == "MCS"
def test_psc_definition_navigation_uses_library_uri(tmp_path, monkeypatch):
import lsp_server
from test_psc_classes import setup_project, caller, CLASS as LIBRARY_SOURCE
server, root, _, script = setup_project(tmp_path, monkeypatch)
server.refresh_psc_scripts([root])
for word, source in [
("MCS", "set obj [MCS new]"),
("initOrg", "set obj [MCS new]\n$obj initOrg 1 2 3"),
]:
uri, _ = caller(server, root, source)
lines = source.splitlines()
position = lsp.Position(line=len(lines) - 1, character=lines[-1].index(word) + 1)
result = lsp_server.goto_definition(lsp.DefinitionParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=position))
assert result is not None and len(result) == 1
assert server.paths_equal(script, uris.to_fs_path(result[0].uri))
assert target_text(result[0], LIBRARY_SOURCE) == word