feat(navigation): add symbol index and LSP navigation features
The changes introduce a Tcl symbol index powering LSP navigation features across the workspace. A navigation API exposes snapshots and update hooks, enabling goto-definition, references, and rename using the index. Background indexing now watches Tcl files and rebuilds the index to stay in sync. - Add Tcl symbol index and navigation snapshot API - Wire go-to-definition, references, and rename using the index - Watch Tcl files and refresh the index in the background
This commit is contained in:
@@ -51,6 +51,7 @@ def test_duplicate_proc_stays_indexed_when_other_file_is_removed(tmp_path: Path)
|
||||
assert first.path not in completions
|
||||
assert first.path not in signatures
|
||||
assert first.path not in docs
|
||||
assert first.path not in server.navigation_snapshot()
|
||||
assert "shared" in signatures[second.path]
|
||||
assert server.diagnostic_snapshot(first.uri) is None
|
||||
|
||||
@@ -88,6 +89,7 @@ def test_delete_and_rename_notifications_update_index(tmp_path: Path, monkeypatc
|
||||
)
|
||||
_, signatures, _ = server.index_snapshot()
|
||||
assert deleted.path not in signatures
|
||||
assert deleted.path not in server.navigation_snapshot()
|
||||
|
||||
old_path = tmp_path / "old.tcl"
|
||||
new_path = tmp_path / "new.tcl"
|
||||
@@ -112,6 +114,10 @@ def test_delete_and_rename_notifications_update_index(tmp_path: Path, monkeypatc
|
||||
if server.paths_equal(indexed_path, new_path)
|
||||
)
|
||||
assert "renamed_proc" in renamed_signatures
|
||||
assert any(
|
||||
server.paths_equal(indexed_path, new_path)
|
||||
for indexed_path in server.navigation_snapshot()
|
||||
)
|
||||
|
||||
|
||||
def test_parallel_file_indexing_keeps_every_file(tmp_path: Path):
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
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 lsprotocol.types as lsp # type: ignore
|
||||
from pygls.workspace.text_document import TextDocument
|
||||
|
||||
import lsp_server
|
||||
from lsp_tclserver import TclLanguageServer
|
||||
from tools.navigation import (
|
||||
SymbolIdentity,
|
||||
build_file_symbol_index,
|
||||
definition_identities,
|
||||
matching_occurrences,
|
||||
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
|
||||
Reference in New Issue
Block a user