feat(tcloo): add document-local TclOO completions, signature help and hints
Add conservative, document-local TclOO type inference and tooling so the server can offer method completions, signature help, and inlay parameter hints for statically resolvable TclOO receivers (including `new`/`create`, `my`, and simple return-chains). Also surface class names as completion items and emit semantic tokens for class declarations/references. Notable changes: - New tcloo_* tools: completion, symbols, and argument parsing; integrated into on_completion, signature_help, inlay hint generation and semantic token highlighting. Completions are returned early when an OO receiver context is detected. - Use a completion-friendly parser fallback when the main AST fails (TclSyntaxError) so editing-in-progress code still yields useful completions. - Add CompletionItemKind.Class to command kinds, exclude class items from the poco completion name cache, and include new unit tests for the TclOO helpers.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import lsprotocol.types as lsp
|
||||
import pytest
|
||||
|
||||
from tools.inlay_hint import InlayHintGenerator
|
||||
from tools.parser import CustomParser
|
||||
from tools.tcloo_arguments import method_parameters, method_signature_help
|
||||
|
||||
|
||||
CLASS = """oo::class create MCS {
|
||||
constructor {name {size 3}} {}
|
||||
method initValue {i value} {return [self]}
|
||||
method format {value {precision 7}} {}
|
||||
method many {first args} {}
|
||||
method empty {} {}
|
||||
method internal {} {my initValue 0 10}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def signature(source):
|
||||
prefix, suffix = source.split("|")
|
||||
position = lsp.Position(line=prefix.count("\n"), character=len(prefix.rsplit("\n", 1)[-1].encode("utf-16-le")) // 2)
|
||||
return method_signature_help(prefix + suffix, position)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tail, label, active", [
|
||||
("set mcs [MCS new test]\n$mcs initValue |", "::MCS initValue i value", 0),
|
||||
("set mcs [MCS new test]\n$mcs initValue 0 |", "::MCS initValue i value", 1),
|
||||
("set mcs [MCS new test]\n$mcs format 12 |", "::MCS format value {precision 7}", 1),
|
||||
("set mcs [MCS new test]\n$mcs many 1 2 3 |", "::MCS many first args", 1),
|
||||
("set mcs [MCS new test]\n$mcs empty |", "::MCS empty", None),
|
||||
("MCS create instance test\ninstance initValue 0 |", "::MCS initValue i value", 1),
|
||||
("set mcs [[MCS new test] initValue 0 0]\n$mcs initValue |", "::MCS initValue i value", 0),
|
||||
("set mcs [MCS new test]\nputs [$mcs initValue 0 |", "::MCS initValue i value", 1),
|
||||
("set mcs [MCS new |", "::MCS new name {size 3}", 0),
|
||||
("MCS create instance |", "::MCS create objectName name {size 3}", 1),
|
||||
("set mcs [MCS new test]\nputs 😀; $mcs initValue 0 |", "::MCS initValue i value", 1),
|
||||
])
|
||||
def test_method_signatures(tail, label, active):
|
||||
result = signature(CLASS + tail)
|
||||
assert result is not None
|
||||
assert result.signatures[0].label == label
|
||||
assert result.active_parameter == active
|
||||
|
||||
|
||||
def test_my_signature_and_parameter_spans():
|
||||
result = signature(CLASS.replace("my initValue 0 10", "my initValue 0 |"))
|
||||
assert result.active_parameter == 1
|
||||
info = result.signatures[0]
|
||||
assert [info.label[start:end] for start, end in (p.label for p in info.parameters)] == ["i", "value"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tail", [
|
||||
"$unknown initValue |",
|
||||
"set mcs [MCS new test]\nset mcs text\n$mcs initValue |",
|
||||
"set mcs [MCS new test]\n$mcs initValue [unknown |] 3",
|
||||
"set mcs [MCS new test]\n$mcs initValue|",
|
||||
])
|
||||
def test_unknown_receivers_and_nested_commands(tail):
|
||||
assert signature(CLASS + tail) is None
|
||||
|
||||
|
||||
def test_optional_parameter_with_spaced_default():
|
||||
parameters = method_parameters('value {description {hello world}} args')
|
||||
assert [p.name for p in parameters] == ["value", "description", "args"]
|
||||
assert parameters[1].label == "{description {hello world}}"
|
||||
assert parameters[-1].variadic
|
||||
|
||||
|
||||
def test_method_inlay_hints_and_existing_preferences():
|
||||
source = CLASS + "set mcs [MCS new test]\n$mcs initValue 0 10\n$mcs many 1 2 3\n$mcs initValue $i $other"
|
||||
tree = CustomParser().parse(source)
|
||||
start = len(CLASS.splitlines()) + 1
|
||||
requested = lsp.Range(start=lsp.Position(line=start, character=0), end=lsp.Position(line=start + 2, character=100))
|
||||
generator = InlayHintGenerator(source, {}, requested_range=requested)
|
||||
hints = generator.generate(tree)
|
||||
assert [hint.label[0].value for hint in hints] == ["i:", "value:", "first:", "args:", "args:", "value:"]
|
||||
assert [source.splitlines()[hint.position.line][hint.position.character:] for hint in hints[:2]] == ["0 10", "10"]
|
||||
assert "::MCS initValue i value" in hints[0].tooltip.value
|
||||
generator = InlayHintGenerator(source, {}, requested_range=requested, parameter_names="literals")
|
||||
assert len(generator.generate(tree)) == 5
|
||||
generator = InlayHintGenerator(source, {}, parameter_names="none")
|
||||
assert generator.generate(tree) == []
|
||||
|
||||
|
||||
def test_lsp_signature_help_with_unfinished_bracket(tmp_path, monkeypatch):
|
||||
import lsp_server
|
||||
from test_completion_context import _completion_server
|
||||
|
||||
server, document, _ = _completion_server(tmp_path, monkeypatch)
|
||||
source = CLASS + "set mcs [MCS new test]\nputs [$mcs initValue 0 "
|
||||
document = server.workspace.get_text_document(document.uri)
|
||||
document._source = source
|
||||
document.version = 2
|
||||
result = lsp_server.signature_help(lsp.SignatureHelpParams(
|
||||
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
|
||||
position=lsp.Position(line=len(source.splitlines()) - 1, character=len(source.splitlines()[-1])),
|
||||
))
|
||||
assert result.active_parameter == 1
|
||||
assert result.signatures[0].label == "::MCS initValue i value"
|
||||
@@ -0,0 +1,166 @@
|
||||
import lsprotocol.types as lsp
|
||||
import pytest
|
||||
|
||||
from tools.tcloo_completion import tcloo_completions
|
||||
|
||||
|
||||
CLASS = """oo::class create MCS {
|
||||
constructor {} {my initValue 0 0}
|
||||
method initValue {i value} {return [self]}
|
||||
method initOrg {x y z} {return [my initValue 0 $x]}
|
||||
method toLst {} {return {1 2 3}}
|
||||
method _private {} {return [self]}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def complete(source):
|
||||
offset = source.index("|")
|
||||
before = source[:offset]
|
||||
position = lsp.Position(line=before.count("\n"), character=len(before.rsplit("\n", 1)[-1].encode("utf-16-le")) // 2)
|
||||
return tcloo_completions(source.replace("|", "").splitlines(), position)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [
|
||||
"set mcs [MCS new]\n$mcs |",
|
||||
"set mcs [::MCS new]\n$mcs |",
|
||||
"MCS create instance\ninstance |",
|
||||
"set mcs [MCS create instance]\n$mcs |",
|
||||
"set mcs [[MCS new] initValue 0 0]\n$mcs |",
|
||||
"set mcs [[MCS new] initOrg 1 2 3]\n$mcs |",
|
||||
"set a [MCS new]\nset mcs $a\n$mcs |",
|
||||
"proc run {} {set mcs [MCS new]; $mcs |}",
|
||||
"set mcs [MCS new]\nputs [$mcs |]",
|
||||
"set mcs [MCS new]\nputs [$mcs |",
|
||||
"[MCS new] |",
|
||||
])
|
||||
def test_instances_and_chains(code):
|
||||
items = complete(CLASS + code)
|
||||
assert {item.label for item in items} == {"initValue", "initOrg", "toLst", "destroy"}
|
||||
assert next(item for item in items if item.label == "initValue").detail == "::MCS initValue i value"
|
||||
|
||||
|
||||
def test_my_and_constructor():
|
||||
for source in [CLASS.replace("my initValue 0 0", "my |"), CLASS.replace("return {1 2 3}", "my |")]:
|
||||
assert "_private" in {item.label for item in complete(source)}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [
|
||||
"set mcs [MCS new]\nset mcs text\n$mcs |",
|
||||
"set mcs [MCS new]\nunset mcs\n$mcs |",
|
||||
"proc a {} {set mcs [MCS new]}\nproc b {} {$mcs |}",
|
||||
"set mcs [MCS new]\nproc b {mcs} {$mcs |}",
|
||||
"set mcs [[MCS new] toLst]\n$mcs |",
|
||||
"$mcs |\nset mcs [MCS new]",
|
||||
"set mcs [MCS new]\n$mcs initValue |",
|
||||
"# my |",
|
||||
"puts {my |}",
|
||||
])
|
||||
def test_unknown_and_non_command_contexts(code):
|
||||
assert complete(CLASS + code) is None
|
||||
|
||||
|
||||
def test_prefix_and_replacement():
|
||||
items = complete(CLASS + "set mcs [MCS new]\n$mcs initV|alue")
|
||||
assert [item.label for item in items] == ["initValue"]
|
||||
assert items[0].text_edit.new_text == "initValue"
|
||||
assert items[0].text_edit.range.start.character == 5
|
||||
assert items[0].text_edit.range.end.character == 14
|
||||
|
||||
|
||||
def test_namespace():
|
||||
source = "namespace eval geometry {\n" + CLASS + "set mcs [MCS new]\n$mcs |\n}"
|
||||
assert "initOrg" in {item.label for item in complete(source)}
|
||||
|
||||
|
||||
def test_lsp_space_trigger(tmp_path, monkeypatch):
|
||||
import lsp_server
|
||||
from test_completion_context import _completion_server
|
||||
|
||||
server, document, _ = _completion_server(tmp_path, monkeypatch)
|
||||
source = CLASS + "set mcs [MCS new]\n$mcs "
|
||||
document = server.workspace.get_text_document(document.uri)
|
||||
document._source = source
|
||||
document.version = 2
|
||||
items = lsp_server.on_completion(lsp.CompletionParams(
|
||||
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
|
||||
position=lsp.Position(line=len(source.splitlines()) - 1, character=5),
|
||||
context=lsp.CompletionContext(trigger_kind=lsp.CompletionTriggerKind.TriggerCharacter, trigger_character=" "),
|
||||
)).items
|
||||
assert "initOrg" in {item.label for item in items}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tail", ["set mcs [MC", "set mcs [MC]", "MC"])
|
||||
def test_class_name_completion_while_editing(tmp_path, monkeypatch, tail):
|
||||
import lsp_server
|
||||
from test_completion_context import _completion_server
|
||||
|
||||
server, document, _ = _completion_server(tmp_path, monkeypatch)
|
||||
source = CLASS + tail
|
||||
document = server.workspace.get_text_document(document.uri)
|
||||
document._source = source
|
||||
document.version = 2
|
||||
items = lsp_server.on_completion(lsp.CompletionParams(
|
||||
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
|
||||
position=lsp.Position(line=len(source.splitlines()) - 1,
|
||||
character=len(tail.rstrip("]"))),
|
||||
)).items
|
||||
classes = [item for item in items if item.label == "MCS"]
|
||||
assert len(classes) == 1
|
||||
assert classes[0].kind == lsp.CompletionItemKind.Class
|
||||
|
||||
|
||||
def test_classes_are_indexed_with_their_own_kind(tmp_path, monkeypatch):
|
||||
from test_completion_context import _completion_server, _document
|
||||
|
||||
server, _, _ = _completion_server(tmp_path, monkeypatch)
|
||||
assert server.update_poco_completion_for_file(_document(tmp_path / "class.tcl", CLASS))
|
||||
items = server.completion_items_snapshot()
|
||||
assert any(item.label == "MCS" and item.kind == lsp.CompletionItemKind.Class for item in items)
|
||||
assert "MCS" not in server.custom_function_names_snapshot()
|
||||
|
||||
|
||||
def test_class_command_completions():
|
||||
assert {item.label for item in complete(CLASS + "set mcs [MCS |]")} == {"new", "create"}
|
||||
|
||||
|
||||
def test_semantic_class_declarations_and_uses():
|
||||
from tools.parser import CustomParser
|
||||
from tools.semantic_tokens import _Highlighter, TokenModifier
|
||||
|
||||
source = CLASS + 'set mcs [MCS new]\nset second [::MCS new]\nputs "MCS"\n'
|
||||
tree = CustomParser().parse(source)
|
||||
highlighter = _Highlighter([], {"MCS"})
|
||||
highlighter.highlight_classes(tree)
|
||||
tree.accept(highlighter, recurse=True)
|
||||
line = col = 0
|
||||
classified = []
|
||||
for token in highlighter.tokens():
|
||||
col = col + token.offset if token.line == 0 else token.offset
|
||||
line += token.line
|
||||
text = source.splitlines()[line][col:col + token.length]
|
||||
classified.append((line, col, text, token.tok_type, token.tok_modifiers))
|
||||
classes = [entry for entry in classified if entry[3] == "class"]
|
||||
assert [entry[2] for entry in classes] == ["MCS", "MCS", "::MCS"]
|
||||
assert TokenModifier.declaration in classes[0][4]
|
||||
assert not any(entry[2] in {"MCS", "::MCS"} and entry[3] == "function" for entry in classified)
|
||||
assert len({entry[:2] for entry in classified}) == len(classified)
|
||||
|
||||
|
||||
def test_namespaced_class_symbols_and_method_body_references():
|
||||
from tools.parser import CustomParser
|
||||
from tools.tcloo_symbols import class_completion_items, class_symbols
|
||||
|
||||
tree = CustomParser().parse('''namespace eval geometry {
|
||||
oo::class create MCS {
|
||||
method duplicate {} {return [MCS new]}
|
||||
}
|
||||
set mcs [MCS new]
|
||||
}
|
||||
set mcs [geometry::MCS new]
|
||||
set mcs [::geometry::MCS new]
|
||||
''')
|
||||
declarations, references = class_symbols(tree)
|
||||
assert set(declarations) == {"::geometry::MCS"}
|
||||
assert [node.contents for node in references] == ["MCS", "MCS", "geometry::MCS", "::geometry::MCS"]
|
||||
assert class_completion_items(tree)[0].label == "geometry::MCS"
|
||||
Reference in New Issue
Block a user