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