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:
Christoph Brandau
2026-08-17 09:24:45 +02:00
parent f5bd79f067
commit 35a4357551
8 changed files with 1128 additions and 88 deletions
+192 -81
View File
@@ -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}")