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:
@@ -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
|
||||
Reference in New Issue
Block a user