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:
+192
-81
@@ -45,8 +45,14 @@ from common.load_data import standard_items
|
||||
from tools.folding_ranges import build_folding_ranges
|
||||
from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier
|
||||
from tools.inlay_hint import InlayHintGenerator
|
||||
from tools.navigation import (
|
||||
SymbolIdentity,
|
||||
definition_identities,
|
||||
matching_occurrences,
|
||||
symbol_at_position,
|
||||
workspace_symbols,
|
||||
)
|
||||
from tools.signature_help import build_signature_help
|
||||
from tools.file_sourcing import get_all_psc_files, read_psc_file
|
||||
from lsp_tclserver import TclLanguageServer
|
||||
from pygls.workspace.text_document import TextDocument
|
||||
|
||||
@@ -60,6 +66,12 @@ LSP_SERVER = TclLanguageServer(
|
||||
name="NX Postprocessor Support", version="0.0.1", max_workers=MAX_WORKERS
|
||||
)
|
||||
|
||||
BUILTIN_PROC_NAMES = {
|
||||
item.label
|
||||
for item in standard_items.tcl_keyword_list + standard_items.nx_procs
|
||||
}
|
||||
BUILTIN_VARIABLE_NAMES = {item.label for item in standard_items.nx_variables}
|
||||
|
||||
# **********************************************************
|
||||
# Tool specific code goes below this.
|
||||
# **********************************************************
|
||||
@@ -166,6 +178,16 @@ def did_rename_files(params: lsp.RenameFilesParams) -> None:
|
||||
_index_tcl_file_from_disk(new_index_path.as_uri())
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.WORKSPACE_DID_CHANGE_WATCHED_FILES)
|
||||
def did_change_watched_files(params: lsp.DidChangeWatchedFilesParams) -> None:
|
||||
"""Keep indexes for closed Tcl files synchronized with disk changes."""
|
||||
for change in params.changes:
|
||||
if change.type == lsp.FileChangeType.Deleted:
|
||||
LSP_SERVER.remove_file_state(change.uri)
|
||||
else:
|
||||
_index_tcl_file_from_disk(change.uri)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(
|
||||
lsp.TEXT_DOCUMENT_DIAGNOSTIC,
|
||||
lsp.DiagnosticOptions(
|
||||
@@ -448,74 +470,153 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
||||
|
||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION)
|
||||
def goto_definition(params: lsp.DefinitionParams):
|
||||
"""Provide go-to-definition locations for Tcl procs.
|
||||
|
||||
Strategy:
|
||||
- Find the token under the cursor.
|
||||
- If it matches a custom proc collected in proc_signatures, locate its declaration
|
||||
by searching the current document first, then other indexed files.
|
||||
- Return a Location pointing to the proc name in its declaration line.
|
||||
"""
|
||||
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||
pos = params.position
|
||||
try:
|
||||
line = doc.lines[pos.line]
|
||||
except IndexError:
|
||||
"""Resolve Tcl proc and variable definitions through the symbol index."""
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return None
|
||||
|
||||
# Identify token under cursor
|
||||
token = None
|
||||
for m in re.finditer(r"\b\w+\b", line):
|
||||
if m.start() <= pos.character <= m.end():
|
||||
token = m.group(0)
|
||||
break
|
||||
if not token:
|
||||
indexes, definitions, _, identity = context
|
||||
locations = [
|
||||
lsp.Location(uri=index.uri, range=occurrence.range)
|
||||
for index, occurrence in matching_occurrences(
|
||||
identity, indexes, definitions
|
||||
)
|
||||
if occurrence.is_definition
|
||||
]
|
||||
return _sorted_locations(locations) or None
|
||||
|
||||
|
||||
def _navigation_context(uri: str, position: lsp.Position):
|
||||
indexes = LSP_SERVER.navigation_snapshot()
|
||||
filepath = str(pathlib.Path(uris.to_fs_path(uri)))
|
||||
index = indexes.get(filepath)
|
||||
if index is None:
|
||||
document = LSP_SERVER.workspace.get_text_document(uri)
|
||||
LSP_SERVER.update_poco_completion_for_file(document)
|
||||
indexes = LSP_SERVER.navigation_snapshot()
|
||||
index = indexes.get(filepath)
|
||||
if index is None:
|
||||
return None
|
||||
|
||||
# Helper to search a single source text for a proc declaration
|
||||
def find_decl_in_source(source_text: str, uri: str) -> Optional[lsp.Location]:
|
||||
lines = source_text.split("\n")
|
||||
pattern = re.compile(r"^\s*proc\s+" + re.escape(token) + r"\b")
|
||||
for i, ln in enumerate(lines):
|
||||
m = pattern.match(ln)
|
||||
if m:
|
||||
start_char = ln.find(token)
|
||||
if start_char < 0:
|
||||
start_char = max(m.end() - len(token), 0)
|
||||
start = lsp.Position(i, start_char)
|
||||
end = lsp.Position(i, start_char + len(token))
|
||||
return lsp.Location(uri=uri, range=lsp.Range(start=start, end=end))
|
||||
definitions = definition_identities(indexes)
|
||||
result = symbol_at_position(index, position, definitions)
|
||||
if result is None:
|
||||
return None
|
||||
occurrence, identity = result
|
||||
return indexes, definitions, occurrence, identity
|
||||
|
||||
|
||||
def _sorted_locations(locations: list[lsp.Location]) -> list[lsp.Location]:
|
||||
return sorted(
|
||||
locations,
|
||||
key=lambda location: (
|
||||
location.uri,
|
||||
location.range.start.line,
|
||||
location.range.start.character,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_REFERENCES)
|
||||
def references(params: lsp.ReferenceParams) -> list[lsp.Location]:
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return []
|
||||
|
||||
indexes, definitions, _, identity = context
|
||||
locations = [
|
||||
lsp.Location(uri=index.uri, range=occurrence.range)
|
||||
for index, occurrence in matching_occurrences(
|
||||
identity, indexes, definitions
|
||||
)
|
||||
if params.context.include_declaration or not occurrence.is_definition
|
||||
]
|
||||
return _sorted_locations(locations)
|
||||
|
||||
|
||||
def _is_renamable(
|
||||
identity: SymbolIdentity,
|
||||
indexes,
|
||||
definitions: set[SymbolIdentity],
|
||||
) -> bool:
|
||||
if identity not in definitions or identity.kind not in {"proc", "variable"}:
|
||||
return False
|
||||
|
||||
basename = identity.name.rsplit("::", 1)[-1]
|
||||
if identity.kind == "proc":
|
||||
if basename in BUILTIN_PROC_NAMES:
|
||||
return False
|
||||
definition_count = sum(
|
||||
occurrence.is_definition and occurrence.identity == identity
|
||||
for index in indexes.values()
|
||||
for occurrence in index.occurrences
|
||||
)
|
||||
return definition_count == 1
|
||||
|
||||
return basename not in BUILTIN_VARIABLE_NAMES
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_PREPARE_RENAME)
|
||||
def prepare_rename(params: lsp.PrepareRenameParams):
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return None
|
||||
|
||||
# 1) Search in current document
|
||||
loc = find_decl_in_source(doc.source, doc.uri)
|
||||
if loc:
|
||||
return loc
|
||||
indexes, definitions, occurrence, identity = context
|
||||
if not _is_renamable(identity, indexes, definitions):
|
||||
return None
|
||||
return lsp.PrepareRenameResult_Type1(
|
||||
range=occurrence.range, placeholder=occurrence.placeholder
|
||||
)
|
||||
|
||||
# 2) Search in indexed files from proc_signatures
|
||||
# Build list of candidate files that declare this token as a proc
|
||||
candidate_files: list[str] = []
|
||||
_, proc_signatures, _ = LSP_SERVER.index_snapshot()
|
||||
for file_path, procs in proc_signatures.items():
|
||||
if token in procs:
|
||||
candidate_files.append(file_path)
|
||||
|
||||
for fp in candidate_files:
|
||||
uri = pathlib.Path(fp).as_uri()
|
||||
# Try to get from workspace if available; else read from disk
|
||||
try:
|
||||
other_doc = LSP_SERVER.workspace.get_text_document(uri)
|
||||
source = other_doc.source
|
||||
except Exception:
|
||||
try:
|
||||
source = pathlib.Path(fp).read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
loc = find_decl_in_source(source, uri)
|
||||
if loc:
|
||||
return loc
|
||||
@LSP_SERVER.feature(
|
||||
lsp.TEXT_DOCUMENT_RENAME,
|
||||
lsp.RenameOptions(prepare_provider=True),
|
||||
)
|
||||
def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", params.new_name):
|
||||
return None
|
||||
|
||||
return None
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return None
|
||||
|
||||
indexes, definitions, _, identity = context
|
||||
if not _is_renamable(identity, indexes, definitions):
|
||||
return None
|
||||
|
||||
changes: dict[str, list[lsp.TextEdit]] = {}
|
||||
seen = set()
|
||||
for index, occurrence in matching_occurrences(identity, indexes, definitions):
|
||||
key = (
|
||||
index.uri,
|
||||
occurrence.range.start.line,
|
||||
occurrence.range.start.character,
|
||||
occurrence.range.end.line,
|
||||
occurrence.range.end.character,
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
changes.setdefault(index.uri, []).append(
|
||||
lsp.TextEdit(range=occurrence.range, new_text=params.new_name)
|
||||
)
|
||||
|
||||
for edits in changes.values():
|
||||
edits.sort(
|
||||
key=lambda edit: (
|
||||
edit.range.start.line,
|
||||
edit.range.start.character,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return lsp.WorkspaceEdit(changes=changes)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.WORKSPACE_SYMBOL)
|
||||
def workspace_symbol(params: lsp.WorkspaceSymbolParams):
|
||||
return workspace_symbols(LSP_SERVER.navigation_snapshot(), params.query)
|
||||
|
||||
|
||||
# **********************************************************
|
||||
@@ -590,6 +691,9 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
|
||||
legend=semantic_tokens_legend, full=True, range=False
|
||||
),
|
||||
definition_provider=True,
|
||||
references_provider=True,
|
||||
rename_provider=lsp.RenameOptions(prepare_provider=True),
|
||||
workspace_symbol_provider=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -602,28 +706,35 @@ def initialized(_params: lsp.InitializedParams):
|
||||
try:
|
||||
root = LSP_SERVER.workspace.root_path
|
||||
log_to_output("Background indexing started...")
|
||||
psc_files = get_all_psc_files(pathlib.Path(root))
|
||||
for psc_file in psc_files:
|
||||
poco_files = read_psc_file(psc_file)
|
||||
for sourced_layer in poco_files:
|
||||
file_root = pathlib.Path(root).joinpath(
|
||||
sourced_layer.subfolder if sourced_layer.subfolder else ""
|
||||
root_path = pathlib.Path(root)
|
||||
skipped_directories = {
|
||||
".git",
|
||||
".nox",
|
||||
".venv",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
}
|
||||
tcl_files = (
|
||||
path
|
||||
for path in root_path.rglob("*.tcl")
|
||||
if not any(
|
||||
part.casefold() in skipped_directories
|
||||
for part in path.relative_to(root_path).parts[:-1]
|
||||
)
|
||||
)
|
||||
for filepath in sorted(tcl_files, key=lambda path: str(path).casefold()):
|
||||
try:
|
||||
document = TextDocument(
|
||||
uri=filepath.as_uri(), language_id="tcl"
|
||||
)
|
||||
for tcl_file in sourced_layer.files:
|
||||
filepath = pathlib.Path(file_root).joinpath(f"{tcl_file}.tcl")
|
||||
if not filepath.exists():
|
||||
continue
|
||||
try:
|
||||
document = TextDocument(
|
||||
uri=filepath.as_uri(), language_id="tcl"
|
||||
)
|
||||
LSP_SERVER.update_poco_completion_for_file(
|
||||
document,
|
||||
cache_tree=False,
|
||||
require_file_exists=True,
|
||||
)
|
||||
except Exception as error:
|
||||
log_to_output(f"Fehler beim Parsen von {filepath}: {error}")
|
||||
LSP_SERVER.update_poco_completion_for_file(
|
||||
document,
|
||||
cache_tree=False,
|
||||
require_file_exists=True,
|
||||
)
|
||||
except Exception as error:
|
||||
log_to_output(f"Fehler beim Parsen von {filepath}: {error}")
|
||||
log_to_output("Background indexing completed.")
|
||||
except Exception as e:
|
||||
log_to_output(f"Background indexing failed: {e}")
|
||||
|
||||
Reference in New Issue
Block a user