feat(tools): add .def navigation helpers and tests

Add tools/def_navigation.py providing utilities to locate .def symbols
and occurrences, produce definition/reference locations, build hover
Markdown for addresses (property table with format links and modality
labels), and compute workspace edits for renames (only when a
declaration exists). Helpers include def_symbol_at, def_definition_locations,
def_reference_locations, tcl_def_occurrences, all_def_target_locations,
def_rename_edits and def_hover_markdown, plus small formatting helpers.

Also add server/tests/python_tests/test_def_navigation.py exercising
go-to-definition, hover, references and rename behavior between Tcl and
.def files, including use of unsaved text and ensuring undeclared names
are not renamed. Tests assert address property ordering, modality labels,
and correct edit ordering for workspace edits.
This commit is contained in:
2026-09-24 23:01:05 +02:00
parent 85a8684254
commit a393ca3aec
2 changed files with 433 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
"""Navigation between Tcl code and the block templates and addresses of .def files."""
from __future__ import annotations
from pathlib import Path
import lsprotocol.types as lsp
from tools.def_symbols import ADDRESS, BLOCK_TEMPLATE, FORMAT, DefDeclaration, DefDocument
from tools.navigation import FileSymbolIndex, SymbolOccurrence
DefTarget = tuple[str, str]
_KIND_LABELS = {BLOCK_TEMPLATE: "Block template", ADDRESS: "Address", FORMAT: "Format"}
# Address properties in display order; others follow as declared.
_ADDRESS_PROPERTIES = (
("FORMAT", "Format"),
("LEADER", "Leader"),
("TRAILER", "Trailer"),
("MIN", "Min"),
("MAX", "Max"),
("FORCE", "Modality"),
("ZERO_FORMAT", "Zero format"),
("INCREMENTAL", "Incremental"),
("OMIT", "Omit"),
)
_MODALITY = {
"OFF": "modal, output only on change",
"ONCE": "output once, then modal",
"ALWAYS": "non-modal, always output",
}
def _range(line: int, start: int, end: int) -> lsp.Range:
return lsp.Range(
start=lsp.Position(line=line, character=start),
end=lsp.Position(line=line, character=end),
)
def _contains(line: int, start: int, end: int, position: lsp.Position) -> bool:
return position.line == line and start <= position.character <= end
def def_symbol_at(document: DefDocument, position: lsp.Position) -> tuple[DefTarget, lsp.Range, bool] | None:
"""Return the block template or address at ``position`` of a .def document.
The flag tells whether the position is on a declaration.
"""
for declaration in document.declarations:
if declaration.kind != FORMAT and _contains(declaration.line, declaration.start, declaration.end, position):
return (declaration.kind, declaration.name), _range(declaration.line, declaration.start, declaration.end), True
for reference in document.references:
if _contains(reference.line, reference.start, reference.end, position):
return (reference.kind, reference.name), _range(reference.line, reference.start, reference.end), False
return None
def _uri(path: str, uris: dict[str, str]) -> str:
return uris.get(path) or Path(path).as_uri()
def def_declarations(documents: dict[str, DefDocument], target: DefTarget) -> list[tuple[str, DefDeclaration]]:
kind, name = target
return [
(path, declaration)
for path, document in documents.items()
for declaration in document.declarations
if declaration.kind == kind and declaration.name == name
]
def def_definition_locations(
documents: dict[str, DefDocument], target: DefTarget, uris: dict[str, str] | None = None
) -> list[lsp.Location]:
uris = uris or {}
return [
lsp.Location(uri=_uri(path, uris), range=_range(declaration.line, declaration.start, declaration.end))
for path, declaration in def_declarations(documents, target)
]
def def_reference_locations(
documents: dict[str, DefDocument],
target: DefTarget,
include_declaration: bool,
uris: dict[str, str] | None = None,
) -> list[lsp.Location]:
"""Return .def occurrences: declarations and addresses used in block templates."""
kind, name = target
uris = uris or {}
locations = def_definition_locations(documents, target, uris) if include_declaration else []
for path, document in documents.items():
for reference in document.references:
if reference.kind == kind and reference.name == name:
locations.append(
lsp.Location(uri=_uri(path, uris), range=_range(reference.line, reference.start, reference.end))
)
return locations
def tcl_def_occurrences(
indexes: dict[str, FileSymbolIndex], target: DefTarget
) -> list[tuple[FileSymbolIndex, SymbolOccurrence]]:
kind, name = target
return [
(index, occurrence)
for index in indexes.values()
for occurrence in index.occurrences
if occurrence.identity.kind == kind and occurrence.identity.name == name
]
def all_def_target_locations(
documents: dict[str, DefDocument],
indexes: dict[str, FileSymbolIndex],
target: DefTarget,
include_declaration: bool,
uris: dict[str, str] | None = None,
) -> list[lsp.Location]:
locations = def_reference_locations(documents, target, include_declaration, uris)
locations.extend(
lsp.Location(uri=index.uri, range=occurrence.range) for index, occurrence in tcl_def_occurrences(indexes, target)
)
unique = {}
for location in locations:
key = (location.uri, location.range.start.line, location.range.start.character)
unique.setdefault(key, location)
return sorted(unique.values(), key=lambda item: (item.uri, item.range.start.line, item.range.start.character))
def def_rename_edits(
documents: dict[str, DefDocument],
indexes: dict[str, FileSymbolIndex],
target: DefTarget,
new_name: str,
uris: dict[str, str] | None = None,
) -> lsp.WorkspaceEdit | None:
"""Rename a block template or address in all .def and Tcl files, if it is declared."""
if not def_declarations(documents, target):
return None
changes: dict[str, list[lsp.TextEdit]] = {}
for location in all_def_target_locations(documents, indexes, target, True, uris):
changes.setdefault(location.uri, []).append(lsp.TextEdit(range=location.range, new_text=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)
def _escape_cell(value: str) -> str:
return value.replace("|", "\\|") or " "
def _address_table(declaration: DefDeclaration, formats: dict[str, DefDeclaration]) -> str:
properties = dict(declaration.properties)
rows = []
for key, label in _ADDRESS_PROPERTIES:
value = properties.pop(key, None)
if value is None:
continue
cell = f"`{value}`" if value else ""
if key == "FORMAT" and value in formats:
cell += f" → `{dict(formats[value].properties).get('FORMAT', '')}`"
if key == "FORCE":
cell += f" ({_MODALITY[value.upper()]})" if value.upper() in _MODALITY else ""
rows.append(f"| {label} | {_escape_cell(cell)} |")
rows.extend(f"| {key.title()} | {_escape_cell(f'`{value}`' if value else '')} |" for key, value in properties.items())
if not rows:
return "_No properties_"
return "\n".join(["| Property | Value |", "|---|---|", *rows])
def def_hover_markdown(documents: dict[str, DefDocument], target: DefTarget) -> str | None:
declarations = def_declarations(documents, target)
if not declarations:
return None
formats = {
declaration.name: declaration
for document in documents.values()
for declaration in document.declarations
if declaration.kind == FORMAT
}
kind, name = target
sections = []
for path, declaration in declarations:
header = f"**{_KIND_LABELS[kind]}** `{name}` — {Path(path).name}:{declaration.line + 1}"
if kind == ADDRESS:
body = _address_table(declaration, formats)
else:
body = f"```def\n{declaration.text}\n```"
sections.append(f"{header}\n\n{body}")
return "\n\n---\n\n".join(sections)