Files
nx_post_support/server/tests/python_tests/test_navigation.py
T
Christoph Brandau 89add18273 feat(lsp): add context-aware completion ranking and document highlights
Adds context-aware completion ranking and document highlights for Tcl.
Adds completion_context to distinguish variables and commands.
Wires per-file completion snapshots and ranking into the flow.
Adds document_highlight provider support and tests for highlights.

- Context-aware ranking of completion items using per-file snapshots
- Document highlight provider wired into initialization and tests
- Tests for completion context, ranking, and document highlights
2026-09-03 09:19:50 +02:00

463 lines
15 KiB
Python

import sys
from pathlib import Path
THIS_DIR = Path(__file__).parent
SRC_DIR = THIS_DIR.parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
import lsp_server
import lsprotocol.types as lsp # type: ignore
from lsp_tclserver import TclLanguageServer
from lsprotocol.converters import get_converter
from pygls.workspace.text_document import TextDocument
from tools.navigation import (
SymbolIdentity,
build_file_symbol_index,
call_hierarchy_identity,
call_hierarchy_items,
definition_identities,
incoming_call_hierarchy,
matching_occurrences,
outgoing_call_hierarchy,
symbol_at_position,
workspace_symbols,
)
from tools.parser import CustomParser
def _index(path: Path, source: str):
return build_file_symbol_index(
str(path), path.as_uri(), CustomParser().parse(source)
)
def _document(path: Path, source: str) -> TextDocument:
return TextDocument(
uri=path.as_uri(),
source=source,
version=1,
language_id="tcl",
)
def _position(source: str, token: str, occurrence: int = 0) -> lsp.Position:
offset = -1
for _ in range(occurrence + 1):
offset = source.index(token, offset + 1)
before = source[:offset]
return lsp.Position(
line=before.count("\n"),
character=offset - (before.rfind("\n") + 1),
)
def _range_text(source: str, range_: lsp.Range) -> str:
assert range_.start.line == range_.end.line
line = source.splitlines()[range_.start.line]
return line[range_.start.character : range_.end.character]
def test_proc_references_respect_namespaces_and_root_fallback(tmp_path: Path):
first_source = """proc shared {value} { return $value }
namespace eval shop {
proc shared {value} { return $value }
proc call {} { shared 1 }
}
"""
second_source = """shared 2
namespace eval shop { shared 3 }
::shop::shared 4
"""
first = _index(tmp_path / "first.tcl", first_source)
second = _index(tmp_path / "second.tcl", second_source)
indexes = {first.path: first, second.path: second}
definitions = definition_identities(indexes)
root = SymbolIdentity(kind="proc", name="::shared")
namespaced = SymbolIdentity(kind="proc", name="::shop::shared")
assert len(matching_occurrences(root, indexes, definitions)) == 2
assert len(matching_occurrences(namespaced, indexes, definitions)) == 4
def test_local_variable_identity_does_not_leak_between_procs(tmp_path: Path):
source = """proc first {} {
set value 1
puts $value
}
proc second {} {
set value 2
puts $value
}
"""
index = _index(tmp_path / "locals.tcl", source)
indexes = {index.path: index}
definitions = definition_identities(indexes)
position = _position(source, "$value")
result = symbol_at_position(
index,
lsp.Position(position.line, position.character + 1),
definitions,
)
assert result is not None
_, identity = result
matches = matching_occurrences(identity, indexes, definitions)
assert len(matches) == 2
assert all(match.identity.scope and "::first" in match.identity.scope for _, match in matches)
def test_foreach_binding_can_be_renamed_without_touching_other_proc(
tmp_path: Path,
):
source = """proc first {items} {
foreach item $items { puts $item }
}
proc second {items} {
foreach item $items { puts $item }
}
"""
index = _index(tmp_path / "foreach.tcl", source)
indexes = {index.path: index}
definitions = definition_identities(indexes)
position = _position(source, "item", occurrence=1)
result = symbol_at_position(index, position, definitions)
assert result is not None
_, identity = result
matches = matching_occurrences(identity, indexes, definitions)
assert identity in definitions
assert len(matches) == 2
assert all("::first" in (occurrence.identity.scope or "") for _, occurrence in matches)
def test_variable_ranges_preserve_qualifiers_and_tcl_substitution(tmp_path: Path):
source = """namespace eval shop {
variable value 0
proc use {} {
variable value
set value 1
puts ${value}
}
}
set ::shop::value 2
"""
index = _index(tmp_path / "variables.tcl", source)
indexes = {index.path: index}
definitions = definition_identities(indexes)
identity = SymbolIdentity(kind="variable", name="::shop::value")
matches = matching_occurrences(identity, indexes, definitions)
assert len(matches) == 5
assert all(_range_text(source, occurrence.range) == "value" for _, occurrence in matches)
def test_qualified_proc_body_and_variable_import_use_declared_namespace(
tmp_path: Path,
):
source = """namespace eval current {
proc ::other::use {} {
variable value
puts $value
variable ::external::setting
puts $setting
}
}
namespace eval other { variable value 1 }
namespace eval external { variable setting 2 }
"""
index = _index(tmp_path / "qualified.tcl", source)
indexes = {index.path: index}
definitions = definition_identities(indexes)
other_value = SymbolIdentity(kind="variable", name="::other::value")
external_setting = SymbolIdentity(
kind="variable", name="::external::setting"
)
assert len(matching_occurrences(other_value, indexes, definitions)) == 3
assert len(matching_occurrences(external_setting, indexes, definitions)) == 3
def test_workspace_symbols_include_procs_namespaces_and_global_variables(tmp_path: Path):
source = """set globalValue 1
set globalValue 2
proc rootProc {} { return }
namespace eval shop { proc namespacedProc {} { return } }
"""
index = _index(tmp_path / "symbols.tcl", source)
symbols = workspace_symbols({index.path: index}, "")
names = [symbol.name for symbol in symbols]
assert names.count("globalValue") == 1
assert "rootProc" in names
assert "shop" in names
assert "shop::namespacedProc" in names
def test_lsp_references_definition_rename_and_workspace_symbols(
tmp_path: Path, monkeypatch
):
declaration_source = "proc customProc {value} { return $value }\n"
usage_source = "set result [customProc 1]\n"
declaration = _document(tmp_path / "declaration.tcl", declaration_source)
usage = _document(tmp_path / "usage.tcl", usage_source)
server = TclLanguageServer(name="navigation-test", version="1", max_workers=1)
assert server.update_poco_completion_for_file(declaration)
assert server.update_poco_completion_for_file(usage)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
position = _position(usage_source, "customProc")
identifier = lsp.TextDocumentIdentifier(uri=usage.uri)
definitions = lsp_server.goto_definition(
lsp.DefinitionParams(text_document=identifier, position=position)
)
assert definitions is not None
assert len(definitions) == 1
assert definitions[0].uri == declaration.uri
references = lsp_server.references(
lsp.ReferenceParams(
text_document=identifier,
position=position,
context=lsp.ReferenceContext(include_declaration=True),
)
)
assert len(references) == 2
prepared = lsp_server.prepare_rename(
lsp.PrepareRenameParams(text_document=identifier, position=position)
)
assert prepared is not None
assert prepared.placeholder == "customProc"
edit = lsp_server.rename(
lsp.RenameParams(
text_document=identifier,
position=position,
new_name="renamedProc",
)
)
assert edit is not None
assert edit.changes is not None
assert set(edit.changes) == {declaration.uri, usage.uri}
assert all(
text_edit.new_text == "renamedProc"
for edits in edit.changes.values()
for text_edit in edits
)
symbols = lsp_server.workspace_symbol(
lsp.WorkspaceSymbolParams(query="custom")
)
assert [symbol.name for symbol in symbols] == ["customProc"]
def test_duplicate_proc_definition_cannot_be_renamed(tmp_path: Path, monkeypatch):
server = TclLanguageServer(name="navigation-test", version="1", max_workers=1)
documents = [
_document(tmp_path / f"duplicate_{number}.tcl", "proc duplicate {} { return }")
for number in range(2)
]
for document in documents:
assert server.update_poco_completion_for_file(document)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
result = lsp_server.prepare_rename(
lsp.PrepareRenameParams(
text_document=lsp.TextDocumentIdentifier(uri=documents[0].uri),
position=lsp.Position(line=0, character=6),
)
)
assert result is None
def test_call_hierarchy_tracks_cross_file_event_calls(tmp_path: Path, monkeypatch):
library_source = """proc leaf {} { return }
namespace eval shop {
proc middle {} {
::leaf
}
}
"""
event_source = """proc MOM_linear_move {} {
::shop::middle
::shop::middle
puts done
}
"""
bootstrap_source = "::shop::middle\n"
library = _document(tmp_path / "library.tcl", library_source)
event = _document(tmp_path / "event.tcl", event_source)
bootstrap = _document(tmp_path / "bootstrap.tcl", bootstrap_source)
server = TclLanguageServer(
name="call-hierarchy-test", version="1", max_workers=1
)
assert server.update_poco_completion_for_file(library)
assert server.update_poco_completion_for_file(event)
assert server.update_poco_completion_for_file(bootstrap)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
prepared = lsp_server.prepare_call_hierarchy(
lsp.CallHierarchyPrepareParams(
text_document=lsp.TextDocumentIdentifier(uri=event.uri),
position=_position(event_source, "middle"),
)
)
assert prepared is not None
assert len(prepared) == 1
middle = prepared[0]
assert middle.name == "shop::middle"
assert middle.kind == lsp.SymbolKind.Function
assert middle.uri == library.uri
assert call_hierarchy_identity(middle) == SymbolIdentity(
kind="proc", name="::shop::middle"
)
incoming = lsp_server.incoming_calls(
lsp.CallHierarchyIncomingCallsParams(item=middle)
)
assert len(incoming) == 2
event_call = next(
call for call in incoming if call.from_.kind == lsp.SymbolKind.Event
)
file_call = next(
call for call in incoming if call.from_.kind == lsp.SymbolKind.File
)
assert event_call.from_.name == "MOM_linear_move"
assert len(event_call.from_ranges) == 2
assert file_call.from_.name == "bootstrap.tcl"
assert len(file_call.from_ranges) == 1
incoming_payload = get_converter().unstructure(event_call)
assert incoming_payload["from"]["name"] == "MOM_linear_move"
assert len(incoming_payload["fromRanges"]) == 2
assert incoming_payload["from"]["data"]["source"] == (
"nx-post-support.call-hierarchy"
)
outgoing = lsp_server.outgoing_calls(
lsp.CallHierarchyOutgoingCallsParams(item=middle)
)
assert len(outgoing) == 1
assert outgoing[0].to.name == "leaf"
assert _range_text(library_source, outgoing[0].from_ranges[0]) == "leaf"
event_item = event_call.from_
event_outgoing = lsp_server.outgoing_calls(
lsp.CallHierarchyOutgoingCallsParams(item=event_item)
)
assert len(event_outgoing) == 1
assert event_outgoing[0].to.name == "shop::middle"
assert len(event_outgoing[0].from_ranges) == 2
file_outgoing = lsp_server.outgoing_calls(
lsp.CallHierarchyOutgoingCallsParams(item=file_call.from_)
)
assert len(file_outgoing) == 1
assert file_outgoing[0].to.name == "shop::middle"
assert len(file_outgoing[0].from_ranges) == 1
def test_call_hierarchy_ignores_dynamic_and_ambiguous_calls(tmp_path: Path):
caller_source = """proc caller {command} {
$command
duplicate
}
"""
caller = _index(tmp_path / "caller.tcl", caller_source)
duplicate_a = _index(tmp_path / "duplicate_a.tcl", "proc duplicate {} {}\n")
duplicate_b = _index(tmp_path / "duplicate_b.tcl", "proc duplicate {} {}\n")
indexes = {
caller.path: caller,
duplicate_a.path: duplicate_a,
duplicate_b.path: duplicate_b,
}
definitions = definition_identities(indexes)
caller_identity = SymbolIdentity(kind="proc", name="::caller")
duplicate_identity = SymbolIdentity(kind="proc", name="::duplicate")
assert call_hierarchy_items(duplicate_identity, indexes) == []
assert (
incoming_call_hierarchy(duplicate_identity, indexes, definitions) == []
)
assert outgoing_call_hierarchy(caller_identity, indexes, definitions) == []
def test_call_hierarchy_item_uses_whole_proc_range(tmp_path: Path):
source = """proc multiline {} {
return
}
"""
index = _index(tmp_path / "range.tcl", source)
items = call_hierarchy_items(
SymbolIdentity(kind="proc", name="::multiline"),
{index.path: index},
)
assert len(items) == 1
assert items[0].selection_range.start == lsp.Position(line=0, character=5)
assert items[0].range.start == lsp.Position(line=0, character=0)
assert items[0].range.end.line == 2
def test_document_highlight_marks_local_reads_and_writes(tmp_path: Path, monkeypatch):
source = """proc first {} {
set value 1
puts $value
incr value
}
proc second {} {
set value 2
puts $value
}
"""
document = _document(tmp_path / "highlights.tcl", source)
server = TclLanguageServer(name="highlight-test", version="1", max_workers=1)
assert server.update_poco_completion_for_file(document)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
position = _position(source, "$value")
highlights = lsp_server.document_highlight(
lsp.DocumentHighlightParams(
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
position=lsp.Position(position.line, position.character + 1),
)
)
assert len(highlights) == 3
assert [highlight.kind for highlight in highlights] == [
lsp.DocumentHighlightKind.Write,
lsp.DocumentHighlightKind.Read,
lsp.DocumentHighlightKind.Write,
]
assert all(_range_text(source, highlight.range) == "value" for highlight in highlights)
def test_document_highlight_marks_proc_definition_and_calls(tmp_path: Path, monkeypatch):
source = """proc target {} { return }
proc caller {} {
target
target
}
"""
document = _document(tmp_path / "proc_highlights.tcl", source)
server = TclLanguageServer(name="highlight-test", version="1", max_workers=1)
assert server.update_poco_completion_for_file(document)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
highlights = lsp_server.document_highlight(
lsp.DocumentHighlightParams(
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
position=_position(source, "target"),
)
)
assert len(highlights) == 3
assert all(
highlight.kind == lsp.DocumentHighlightKind.Text
for highlight in highlights
)