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:
Christoph Brandau
2026-09-11 23:38:42 +02:00
parent c28836933c
commit f88a50d4ab
12 changed files with 1007 additions and 42 deletions
@@ -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"