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:
@@ -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)
|
||||
Reference in New Issue
Block a user