diff --git a/server/src/tools/def_navigation.py b/server/src/tools/def_navigation.py new file mode 100644 index 0000000..b04bb45 --- /dev/null +++ b/server/src/tools/def_navigation.py @@ -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) diff --git a/server/tests/python_tests/test_def_navigation.py b/server/tests/python_tests/test_def_navigation.py new file mode 100644 index 0000000..4ac219a --- /dev/null +++ b/server/tests/python_tests/test_def_navigation.py @@ -0,0 +1,242 @@ +"""Go to Definition, hover, references and rename between Tcl and .def files.""" + +from collections import namedtuple +from pathlib import Path + +import lsprotocol.types as lsp # type: ignore +from pygls.workspace import Workspace + +import lsp_server +from lsp_tclserver import TclLanguageServer +from tools.def_symbols import ADDRESS, BLOCK_TEMPLATE, FORMAT, parse_def_document + +PSC = """ + + + + + + + +""" + +DEF = """MACHINE Default + +FORMATTING +{ + FORMAT Coordinate "&__4.3_" + ADDRESS SPOS + { + FORMAT Coordinate + FORCE off + MAX 99999.999 Abort + MIN -99999.999 Abort + LEADER "SPOS=" + } + # ADDRESS commented_out + BLOCK_TEMPLATE steady_rest + { + SPOS[$mom_pos(0)] + Text[M60]\\opt + } +} +""" + +TCL = """proc MOM_steady {} { + MOM_do_template steady_rest + MOM_force Once SPOS X + MOM_ask_address_value "SPOS" + set name steady_rest +} +""" + + +def _project(tmp_path: Path, monkeypatch): + (tmp_path / "service").mkdir() + (tmp_path / "post.psc").write_text(PSC, encoding="utf-8") + def_file = tmp_path / "service" / "service.def" + def_file.write_text(DEF, encoding="utf-8") + tcl_file = tmp_path / "caller.tcl" + tcl_file.write_text(TCL, encoding="utf-8") + + server = TclLanguageServer(name="def-navigation-test", version="1", max_workers=1) + server.protocol._workspace = Workspace( # pylint: disable=protected-access + root_uri=tmp_path.as_uri(), + sync_kind=lsp.TextDocumentSyncKind.Incremental, + workspace_folders=[lsp.WorkspaceFolder(uri=tmp_path.as_uri(), name="root")], + position_encoding=lsp.PositionEncodingKind.Utf16, + ) + monkeypatch.setattr(lsp_server, "LSP_SERVER", server) + server.refresh_psc_scripts([tmp_path]) + server.workspace.put_text_document( + lsp.TextDocumentItem(uri=tcl_file.as_uri(), language_id="tcl", version=1, text=TCL) + ) + server.update_poco_completion_for_file(server.workspace.get_text_document(tcl_file.as_uri())) + return server, def_file, tcl_file + + +def _position(source: str, needle: str, occurrence: int = 0, offset: int = 1) -> lsp.Position: + index = -1 + for _ in range(occurrence + 1): + index = source.index(needle, index + 1) + line = source.count("\n", 0, index) + column = index - (source.rfind("\n", 0, index) + 1) + return lsp.Position(line=line, character=column + offset) + + +def _tcl_params(tcl_file: Path, needle: str, occurrence: int = 0): + return lsp.TextDocumentIdentifier(uri=tcl_file.as_uri()), _position(TCL, needle, occurrence) + + +# The client sends custom .def requests as plain JSON; pygls exposes them as namedtuples. +_Doc = namedtuple("Object", ["uri"]) +_Pos = namedtuple("Object", ["line", "character"]) +_Params = namedtuple("Object", ["textDocument", "position", "text", "includeDeclaration", "newName"]) + + +def _def_params(def_file: Path, needle: str, occurrence: int = 0, text: str = DEF, offset: int = 1, **extra): + position = _position(text, needle, occurrence, offset) + return _Params( + _Doc(def_file.as_uri()), + _Pos(position.line, position.character), + text, + extra.get("includeDeclaration", True), + extra.get("newName", ""), + ) + + +def _lines(locations): + return sorted((Path(location.uri).name, location.range.start.line, location.range.start.character) for location in locations) + + +def test_parse_def_document_declarations_and_references(): + document = parse_def_document(DEF) + kinds = [(item.kind, item.name) for item in document.declarations] + assert kinds == [(FORMAT, "Coordinate"), (ADDRESS, "SPOS"), (BLOCK_TEMPLATE, "steady_rest")] + address = document.declarations[1] + assert (address.line, address.start, address.end) == (5, 12, 16) + assert dict(address.properties)["LEADER"] == '"SPOS="' + assert [(ref.name, ref.line, ref.container) for ref in document.references] == [ + ("SPOS", 16, "steady_rest"), + ("Text", 17, "steady_rest"), + ] + assert document.declarations[2].text.splitlines()[-1].strip() == "}" + + +def test_tcl_goto_definition_of_template_and_address(tmp_path, monkeypatch): + _, def_file, tcl_file = _project(tmp_path, monkeypatch) + document, position = _tcl_params(tcl_file, "steady_rest") + result = lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position)) + assert _lines(result) == [("service.def", 14, 19)] + + for needle, occurrence in (("SPOS", 0), ("SPOS", 1)): + document, position = _tcl_params(tcl_file, needle, occurrence) + result = lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position)) + assert _lines(result) == [("service.def", 5, 12)] + + +def test_tcl_goto_definition_ignores_plain_words_and_unknown_names(tmp_path, monkeypatch): + _, _, tcl_file = _project(tmp_path, monkeypatch) + # "set name steady_rest" is no template argument. + document, position = _tcl_params(tcl_file, "steady_rest", 1) + assert lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position)) is None + document, position = lsp.TextDocumentIdentifier(uri=tcl_file.as_uri()), _position(TCL, "SPOS X", offset=5) + assert lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position)) is None + + +def test_tcl_hover_shows_template_body_and_address_properties(tmp_path, monkeypatch): + _, _, tcl_file = _project(tmp_path, monkeypatch) + document, position = _tcl_params(tcl_file, "steady_rest") + hover = lsp_server.hover(lsp.HoverParams(text_document=document, position=position)) + assert "Block template" in hover.contents.value + assert "SPOS[$mom_pos(0)]" in hover.contents.value + assert "```def" in hover.contents.value + + document, position = _tcl_params(tcl_file, "SPOS") + value = lsp_server.hover(lsp.HoverParams(text_document=document, position=position)).contents.value + assert "| Format | `Coordinate` → `\"&__4.3_\"` |" in value + assert '| Leader | `"SPOS="` |' in value + assert "| Min | `-99999.999 Abort` |" in value + assert "| Max | `99999.999 Abort` |" in value + assert "| Modality | `off` (modal, output only on change) |" in value + + +def test_tcl_references_include_def_declaration_and_template_elements(tmp_path, monkeypatch): + _, _, tcl_file = _project(tmp_path, monkeypatch) + document, position = _tcl_params(tcl_file, "SPOS") + result = lsp_server.references( + lsp.ReferenceParams( + text_document=document, + position=position, + context=lsp.ReferenceContext(include_declaration=True), + ) + ) + assert _lines(result) == [ + ("caller.tcl", 2, 19), + ("caller.tcl", 3, 27), + ("service.def", 5, 12), + ("service.def", 16, 8), + ] + result = lsp_server.references( + lsp.ReferenceParams( + text_document=document, + position=position, + context=lsp.ReferenceContext(include_declaration=False), + ) + ) + assert ("service.def", 5, 12) not in _lines(result) + + +def test_tcl_rename_updates_def_and_tcl(tmp_path, monkeypatch): + _, def_file, tcl_file = _project(tmp_path, monkeypatch) + document, position = _tcl_params(tcl_file, "SPOS") + prepared = lsp_server.prepare_rename(lsp.PrepareRenameParams(text_document=document, position=position)) + assert prepared.placeholder == "SPOS" + + edit = lsp_server.rename(lsp.RenameParams(text_document=document, position=position, new_name="STEADY_POS")) + edits = {Path(uri).name: [(e.range.start.line, e.range.start.character) for e in items] for uri, items in edit.changes.items()} + assert edits == {"caller.tcl": [(3, 27), (2, 19)], "service.def": [(16, 8), (5, 12)]} + assert all(e.new_text == "STEADY_POS" for items in edit.changes.values() for e in items) + + +def test_undeclared_names_are_not_renamed(tmp_path, monkeypatch): + _, _, tcl_file = _project(tmp_path, monkeypatch) + document = lsp.TextDocumentIdentifier(uri=tcl_file.as_uri()) + position = _position(TCL, "SPOS X", offset=5) + assert lsp_server.prepare_rename(lsp.PrepareRenameParams(text_document=document, position=position)) is None + + +def test_def_requests_resolve_declarations_and_elements(tmp_path, monkeypatch): + _, def_file, _ = _project(tmp_path, monkeypatch) + # Address element inside a block template -> ADDRESS declaration. + result = lsp_server.def_definition(_def_params(def_file, "SPOS[", offset=1)) + assert _lines(result) == [("service.def", 5, 12)] + + hover = lsp_server.def_hover(_def_params(def_file, "steady_rest")) + assert "Text[M60]" in hover.contents.value + + references = lsp_server.def_references(_def_params(def_file, "ADDRESS SPOS", offset=9)) + assert _lines(references) == [ + ("caller.tcl", 2, 19), + ("caller.tcl", 3, 27), + ("service.def", 5, 12), + ("service.def", 16, 8), + ] + assert lsp_server.def_hover(_def_params(def_file, "MACHINE")) is None + + +def test_def_requests_use_unsaved_text(tmp_path, monkeypatch): + _, def_file, _ = _project(tmp_path, monkeypatch) + text = DEF.replace("BLOCK_TEMPLATE steady_rest", "BLOCK_TEMPLATE steady_rest_new") + params = _def_params(def_file, "steady_rest_new", text=text, newName="rest") + assert lsp_server.def_prepare_rename(params).placeholder == "steady_rest_new" + edit = lsp_server.def_rename(params) + assert list(edit.changes) == [def_file.as_uri()] + + +def test_def_rename_updates_tcl_callers(tmp_path, monkeypatch): + _, def_file, _ = _project(tmp_path, monkeypatch) + edit = lsp_server.def_rename(_def_params(def_file, "steady_rest", newName="lunette")) + edits = {Path(uri).name: [(e.range.start.line, e.range.start.character) for e in items] for uri, items in edit.changes.items()} + assert edits == {"caller.tcl": [(1, 20)], "service.def": [(14, 19)]} + assert lsp_server.def_rename(_def_params(def_file, "steady_rest", newName="bad name")) is None