diff --git a/README.md b/README.md index 6951517..95f495b 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ This project has the following main goals: - [Full Galaxy tool linter integration](#document-validation) - [Document Outline](#document-outline) - [Insert param reference](#insert-param-reference) - - [Automatically update tests to comply with 24.2 profile validation](#automatically-update-tests-to-comply-with-242-profile-validation) _New feature!_ ✨ + - [Automatically update tests to comply with 24.2 profile validation](#automatically-update-tests-to-comply-with-242-profile-validation) + - [Rename tool parameters](#rename-tool-parameters) _New feature!_ ✨ # Getting Started @@ -182,3 +183,13 @@ A new command is available to update old tests to comply with the 24.2 profile v You can use this command to automatically update the tests to meet the new validation rules. The command is accessible from the command palette as `Galaxy Tools: Update existing test cases to comply with 24.2 profile validation`, or you can use the default key binding `ctrl+alt+u`. For more details about how this command works, you can check an example in this [Pull Request](https://github.com/galaxyproject/galaxy-language-server/pull/278). + +## Rename Tool Parameters + +> This feature requires the optional [`galaxy-tool-source`](https://pypi.org/project/galaxy-tool-source/) engine. Install it into the same environment as the language server with `pip install galaxy-tool-source`; the feature self-registers once that package is present. + +Rename a tool parameter and every reference to it in a single edit. Place the cursor on a `` (or any `$param` reference) and invoke **Rename Symbol** (`F2`). The rename updates the definition and every live reference — through `#if` directives, dotted `$param.metadata` accesses, output labels, by-name cross-reference attributes and the `` mirror — while leaving occurrences inside `#raw` blocks, `##` comments and `` prose untouched. + +The rename **spans imported macro files**: a reference that lives only in a macro the tool ``s is rewritten too, so it is never silently left dangling. When a macro is **shared** by other tools in the workspace, the rename is refused (rather than leave the sibling tools referencing the old name) with a pointer to the [`galaxy-tool-refactor`](https://github.com/galaxyproject/galaxy-tool-refactor) CLI (`rename-param --across-importers`), which renames across every importer at once. If a parameter cannot be renamed safely, the editor reports the specific reason. + +**Find All References** for the same parameters is available too (`Shift+F12`), listing every occurrence across the tool and its imported macros. diff --git a/client/README.md b/client/README.md index 47bbace..cb088a1 100644 --- a/client/README.md +++ b/client/README.md @@ -40,7 +40,8 @@ Since version `0.4.0` you can use some of the cool features of [planemo](https:/ - [Full Galaxy tool linter integration](#document-validation) - [Document Outline](#document-outline) - [Insert param reference](#insert-param-reference) - - [Automatically update tests to comply with 24.2 profile validation](#automatically-update-tests-to-comply-with-242-profile-validation) _New feature!_ ✨ + - [Automatically update tests to comply with 24.2 profile validation](#automatically-update-tests-to-comply-with-242-profile-validation) + - [Rename tool parameters](#rename-tool-parameters) _New feature!_ ✨ # Installation @@ -219,3 +220,13 @@ A new command is available to update old tests to comply with the 24.2 profile v You can use this command to automatically update the tests to meet the new validation rules. The command is accessible from the command palette as `Galaxy Tools: Update existing test cases to comply with 24.2 profile validation`, or you can use the default key binding `ctrl+alt+u`. For more details about how this command works, you can check an example in this [Pull Request](https://github.com/galaxyproject/galaxy-language-server/pull/278). + +## Rename Tool Parameters + +> This feature requires the optional [`galaxy-tool-source`](https://pypi.org/project/galaxy-tool-source/) engine. Install it into the same environment as the language server with `pip install galaxy-tool-source`; the feature self-registers once that package is present. + +Rename a tool parameter and every reference to it in a single edit. Place the cursor on a `` (or any `$param` reference) and invoke **Rename Symbol** (`F2`). The rename updates the definition and every live reference — through `#if` directives, dotted `$param.metadata` accesses, output labels, by-name cross-reference attributes and the `` mirror — while leaving occurrences inside `#raw` blocks, `##` comments and `` prose untouched. + +The rename **spans imported macro files**: a reference that lives only in a macro the tool ``s is rewritten too, so it is never silently left dangling. When a macro is **shared** by other tools in the workspace, the rename is refused (rather than leave the sibling tools referencing the old name) with a pointer to the [`galaxy-tool-refactor`](https://github.com/galaxyproject/galaxy-tool-refactor) CLI (`rename-param --across-importers`), which renames across every importer at once. If a parameter cannot be renamed safely, the editor reports the specific reason. + +**Find All References** for the same parameters is available too (`Shift+F12`), listing every occurrence across the tool and its imported macros. diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md index 005bc5e..91ef607 100644 --- a/server/CHANGELOG.md +++ b/server/CHANGELOG.md @@ -1,5 +1,11 @@ # Galaxy Language Server Changelog +## [Unreleased] + +### Added + +- Add **Rename Symbol** and **Find All References** for tool parameters, spanning the tool and its imported macro files. Backed by the optional [`galaxy-tool-source`](https://pypi.org/project/galaxy-tool-source/) engine ([#331](https://github.com/galaxyproject/galaxy-language-server/pull/331)). + ## [0.15.0] - 2026-02-16 ### Changed diff --git a/server/galaxyls/server.py b/server/galaxyls/server.py index 404105c..8088dd1 100644 --- a/server/galaxyls/server.py +++ b/server/galaxyls/server.py @@ -12,6 +12,9 @@ TEXT_DOCUMENT_DOCUMENT_SYMBOL, TEXT_DOCUMENT_FORMATTING, TEXT_DOCUMENT_HOVER, + TEXT_DOCUMENT_PREPARE_RENAME, + TEXT_DOCUMENT_REFERENCES, + TEXT_DOCUMENT_RENAME, WORKSPACE_DID_CHANGE_CONFIGURATION, CodeAction, CodeActionKind, @@ -37,18 +40,27 @@ Location, LogMessageParams, MessageType, + PrepareRenameParams, PublishDiagnosticsParams, + Range, + ReferenceParams, + RenameOptions, + RenameParams, ShowMessageParams, TextDocumentIdentifier, TextDocumentPositionParams, TextEdit, + WorkspaceEdit, ) from pygls.lsp.server import LanguageServer from pygls.workspace import TextDocument from galaxyls.config import CompletionMode, GalaxyToolsConfiguration from galaxyls.constants import Commands -from galaxyls.services.language import GalaxyToolLanguageService +from galaxyls.services.language import ( + RENAME_FEATURE_AVAILABLE, + GalaxyToolLanguageService, +) from galaxyls.services.validation import DocumentValidator from galaxyls.services.xml.document import XmlDocument from galaxyls.services.xml.parser import XmlDocumentParser @@ -175,6 +187,35 @@ def definition(server: GalaxyToolsLanguageServer, params: TextDocumentPositionPa return None +# The parameter Rename Symbol / Find References feature depends on an optional engine +# (galaxy-tool-source); only advertise it to clients when that engine is installed. +if RENAME_FEATURE_AVAILABLE: + + @language_server.feature(TEXT_DOCUMENT_PREPARE_RENAME) + def prepare_rename(server: GalaxyToolsLanguageServer, params: PrepareRenameParams) -> Range | None: + """Checks whether the symbol under the cursor (a tool parameter) can be renamed.""" + document = _get_valid_document(server, params.text_document.uri) + if document: + return server.service.prepare_param_rename(document, params.position) + return None + + @language_server.feature(TEXT_DOCUMENT_RENAME, RenameOptions(prepare_provider=True)) + def rename(server: GalaxyToolsLanguageServer, params: RenameParams) -> WorkspaceEdit | None: + """Renames a tool parameter and every reference to it across the document.""" + document = _get_valid_document(server, params.text_document.uri) + if document: + return server.service.rename_param(document, params.position, params.new_name) + return None + + @language_server.feature(TEXT_DOCUMENT_REFERENCES) + def references(server: GalaxyToolsLanguageServer, params: ReferenceParams) -> list[Location] | None: + """Finds every occurrence of the tool parameter under the cursor.""" + document = _get_valid_document(server, params.text_document.uri) + if document: + return server.service.get_param_references(document, params.position) + return None + + @language_server.feature(TEXT_DOCUMENT_DOCUMENT_LINK) def document_link(server: GalaxyToolsLanguageServer, params: DocumentLinkParams) -> list[DocumentLink]: document = _get_valid_document(server, params.text_document.uri) diff --git a/server/galaxyls/services/language.py b/server/galaxyls/services/language.py index b7389c0..0282b65 100644 --- a/server/galaxyls/services/language.py +++ b/server/galaxyls/services/language.py @@ -12,6 +12,7 @@ Position, Range, TextEdit, + WorkspaceEdit, ) from pygls.workspace import ( TextDocument, @@ -37,6 +38,16 @@ RefactoringService, RefactorMacrosService, ) + +try: + # Optional: the parameter rename/references engine (galaxy-tool-source). The feature + # self-registers only when it is importable, so the server still works without it. + from galaxyls.services.tools.rename import RenameService + + RENAME_FEATURE_AVAILABLE = True +except ImportError: + RenameService = None # type: ignore[assignment, misc] + RENAME_FEATURE_AVAILABLE = False from galaxyls.services.tools.testing import ToolTestsDiscoveryService from ..config import CompletionMode @@ -76,8 +87,11 @@ def __init__(self) -> None: self.link_provider = DocumentLinksProvider() self.symbols_provider = DocumentSymbolsProvider() self.param_references_provider = ParamReferencesProvider() + self.rename_service = RenameService() if RENAME_FEATURE_AVAILABLE else None def set_workspace(self, workspace: Workspace) -> None: + if self.rename_service is not None: + self.rename_service.set_workspace(workspace) macro_definitions_provider = MacroDefinitionsProvider(workspace) self.definitions_provider = DocumentDefinitionsProvider(macro_definitions_provider) self.completion_service = XmlCompletionService(self.xsd_tree, self.definitions_provider) @@ -174,3 +188,21 @@ def go_to_definition(self, xml_document: XmlDocument, position: Position) -> lis if self.definitions_provider: return self.definitions_provider.go_to_definition(xml_document, position) return None + + def prepare_param_rename(self, document: TextDocument, position: Position) -> Range | None: + """Returns the renameable range under the cursor, or None to reject the rename.""" + if self.rename_service is None: + return None + return self.rename_service.prepare_rename(document, position) + + def rename_param(self, document: TextDocument, position: Position, new_name: str) -> WorkspaceEdit | None: + """Returns a minimal WorkspaceEdit renaming the parameter under the cursor.""" + if self.rename_service is None: + return None + return self.rename_service.rename(document, position, new_name) + + def get_param_references(self, document: TextDocument, position: Position) -> list[Location] | None: + """Returns every occurrence of the parameter under the cursor (definition + references).""" + if self.rename_service is None: + return None + return self.rename_service.find_references(document, position) diff --git a/server/galaxyls/services/tools/rename.py b/server/galaxyls/services/tools/rename.py new file mode 100644 index 0000000..1cebf03 --- /dev/null +++ b/server/galaxyls/services/tools/rename.py @@ -0,0 +1,328 @@ +"""Rename a Galaxy tool parameter (and every reference to it) from the editor. + +This binds the offset-returning rename engine from ``galaxy_tool_source`` (``rename_param_plan``, +which returns minimal ``(start, end, replacement)`` edits over the original source) to the +LSP ``textDocument/prepareRename`` / ``textDocument/rename`` / ``textDocument/references`` +requests. The engine owns the semantics — which ``$param`` references are real (through +``#if`` directives, dotted ``$p.metadata`` accesses, output labels, by-name cross-reference +attributes and ```` mirrors), refusing to touch a ``$param`` inside ``#raw`` / a ``##`` +comment / ```` prose, and bailing atomically when it cannot prove the rewrite safe — +so this layer is only offset/Range conversion plus a human message for each bail reason. + +A parameter is frequently *defined* in the tool but *referenced* inside a macro file it +````s. The rename therefore spans the **bundle**: it runs the engine over the open +tool document and over each imported macro file, assembling a multi-file ``WorkspaceEdit`` +so a reference living only in a macro is not silently left dangling. Macro files are read +through the workspace (honouring unsaved editor buffers) when one is available, else from +disk. The whole rename bails if a macro file references the parameter but cannot be rewritten +safely (the same atomicity the single-tool rename guarantees). + +**Caveat — no shared-macro gate.** An imported macro is rewritten whenever the open tool +references the parameter through it. Editing a macro **shared** by other tools would leave +those tools referencing the old name, and they are not shown in the ``WorkspaceEdit`` the +user reviews. So when a rename would rewrite an imported macro, ``_first_shared_macro`` scans +the workspace for any *other* tool that imports it (an in-binding reverse-import scan, using +only ``galaxy_tool_source``); if one is found the rename is **refused** with a message pointing +at the ``galaxy-tool-refactor`` CLI (``rename-param --across-importers`` renames every +importer in lockstep). This mirrors the CLI's sole-owned default. + +Two limitations remain (see ``docs/upgrade_research/lsp_rename_integration.md`` in the +galaxy-tool-refactor repo): the scan walks the workspace on each macro-touching rename (no +cache yet), and there is no in-editor *consensus* rename — a shared macro is refused, not +widened. With **no** workspace set (e.g. a single unsaved buffer) ownership cannot be +proven, so the gate is skipped and the rename proceeds (the documented no-gate fallback). +""" + +from pathlib import Path + +from galaxy_tool_source.cheetah_rename import RenamePlan, rename_param_plan +from galaxy_tool_source.macros import imported_macro_paths +from lsprotocol.types import ( + Location, + Position, + Range, + TextEdit, + WorkspaceEdit, +) +from lxml import etree +from pygls.exceptions import JsonRpcInvalidParams +from pygls.uris import from_fs_path, to_fs_path +from pygls.workspace import TextDocument, Workspace + +from galaxyls.services.xml.utils import convert_document_offsets_to_range + +# Tags under / whose ``name`` *defines* a parameter/output: a rename is +# only offered for a name defined in THIS document. References to it may live in the tool +# AND in the macro files it ````s — the rename follows the parameter into those +# macro files too (see ``RenameService._macro_targets``), so the rewrite stays complete. +_INPUT_DEFINITION_TAGS = frozenset({"param", "conditional", "repeat", "section"}) +_OUTPUT_DEFINITION_TAGS = frozenset({"data", "collection"}) + +# An identifier probe used only to drive prepareRename/references: a valid Cheetah/Python +# identifier that no real tool parameter is named, so the plan exercises the same code path +# the actual rename will (we read only its edit spans, never the replacement text). +_RENAME_PROBE = "galaxylsRenameProbe" + +# A human message per atomic bail reason the engine can return (see the rename engine's +# module docstring). Shown to the user when a rename cannot proceed. +_BAIL_MESSAGES = { + "invalid-name": "'{new}' is not a valid parameter name.", + "no-op": "The new name is the same as the current name.", + "not-found": "'{old}' is not a renameable tool parameter.", + "shadowed": "Can't safely rename '{old}': it is shadowed by a Cheetah #set/#for/#def binding.", + "mixed-content": "Can't safely rename '{old}': its command/configfile mixes text and child elements.", + "lexer-bail": "Can't safely rename '{old}': a Cheetah section could not be parsed.", + "filter-bare-ref": "Can't safely rename '{old}': it is referenced by bare name in an output .", + "cross-ref-residual": "Can't safely rename '{old}': an unmodeled by-name reference would be left dangling.", +} +_FALLBACK_BAIL_MESSAGE = "Can't compute a precise rename for '{old}' in this document." + + +def _identifier_at(source: str, offset: int) -> str | None: + """The Cheetah/Python identifier covering *offset* in *source*, or ``None``. + + Expands over ``[A-Za-z_]\\w*`` around the cursor (``$`` and ``.`` are not word + characters, so a cursor on ``old`` in ``$cond.old`` yields ``"old"``). Returns ``None`` + when the cursor is not on a word or the run starts with a digit (not an identifier). + """ + length = len(source) + if offset < 0 or offset > length: + return None + start = offset + while start > 0 and (source[start - 1].isalnum() or source[start - 1] == "_"): + start -= 1 + end = offset + while end < length and (source[end].isalnum() or source[end] == "_"): + end += 1 + word = source[start:end] + if not word or not (word[0].isalpha() or word[0] == "_"): + return None + return word + + +def _defined_param_names(source: str) -> set[str]: + """Names defined by an ````/```` element in *source* (best effort).""" + parser = etree.XMLParser(recover=True) + try: + root = etree.fromstring(source.encode("utf-8"), parser) + except (etree.XMLSyntaxError, ValueError): + return set() + if root is None: + return set() + names: set[str] = set() + for section, tags in (("inputs", _INPUT_DEFINITION_TAGS), ("outputs", _OUTPUT_DEFINITION_TAGS)): + container = root.find(section) + if container is None: + continue + for element in container.iter(): + if isinstance(element.tag, str) and element.tag in tags: + name = element.get("name") + if name: + names.add(name) + return names + + +def _text_edits(document: TextDocument, plan: RenamePlan) -> list[TextEdit]: + """Convert a rename plan's offset edits into LSP ``TextEdit``\\ s for *document*.""" + return [ + TextEdit( + range=convert_document_offsets_to_range(document, edit.start, edit.end), + new_text=edit.replacement, + ) + for edit in plan.edits + ] + + +def _bail_error(old: str, new: str, reason: str | None) -> JsonRpcInvalidParams: + """The user-facing error for an atomic rename bail.""" + template = _BAIL_MESSAGES.get(reason or "", _FALLBACK_BAIL_MESSAGE) + return JsonRpcInvalidParams(message=template.format(old=old, new=new)) + + +def _is_tool_file(path: Path) -> bool: + """Whether *path* parses as a Galaxy tool (a ```` root); lenient, never raises.""" + try: + tree = etree.parse(str(path), etree.XMLParser(recover=True)) + except (etree.XMLSyntaxError, OSError): + return False + root = tree.getroot() + return root is not None and root.tag == "tool" + + +class RenameService: + """Provides parameter rename + find-references over a tool document and its macros.""" + + def __init__(self, workspace: Workspace | None = None) -> None: + """*workspace*, when given, makes imported macro reads honour unsaved buffers.""" + self._workspace = workspace + + def set_workspace(self, workspace: Workspace) -> None: + """Record the workspace so imported macro files are read buffer-first.""" + self._workspace = workspace + + def _workspace_roots(self) -> list[Path]: + """The filesystem roots to scan for tools that might share an edited macro.""" + if self._workspace is None: + return [] + roots: list[Path] = [] + if self._workspace.root_path: + roots.append(Path(self._workspace.root_path)) + for folder in (self._workspace.folders or {}).values(): + path = to_fs_path(folder.uri) + if path is not None: + roots.append(Path(path)) + return roots + + def _first_shared_macro( + self, edited_macros: list[TextDocument], document: TextDocument + ) -> tuple[Path, Path] | None: + """The first edited macro that another tool imports, as ``(macro, other_tool)``. + + The editor counterpart of the CLI's sole-owned gate: a macro the rename would + rewrite is *shared* if any other tool in the workspace imports it, so editing it + here would leave that tool referencing the old name. Scans the workspace roots for + tool files and resolves each one's transitive imports. Returns ``None`` when every + edited macro is sole-owned, or when no workspace is available to prove ownership + (the no-gate fallback the caller documents). + """ + roots = self._workspace_roots() + if not roots or not edited_macros: + return None + open_tool = to_fs_path(document.uri) + open_resolved = Path(open_tool).resolve() if open_tool else None + edited = { + Path(path).resolve() + for macro in edited_macros + if (path := to_fs_path(macro.uri)) is not None + } + seen: set[Path] = set() + for root in roots: + for xml in root.rglob("*.xml"): + resolved = xml.resolve() + if resolved in seen or resolved == open_resolved or resolved in edited: + continue + seen.add(resolved) + if not _is_tool_file(xml): + continue + shared = edited & set(imported_macro_paths(xml)) + if shared: + return next(iter(shared)), resolved + return None + + def _macro_targets(self, document: TextDocument) -> list[TextDocument]: + """The macro files *document* imports, as ``TextDocument``\\ s (buffer-aware). + + Resolves the tool's transitive ````s relative to its own location on disk + (``imported_macro_paths``). Each macro is read through the workspace when one is set + — so an unsaved editor buffer wins — otherwise from disk. Returns ``[]`` for an + in-memory document with no filesystem path, or one that imports nothing. + """ + tool_path = to_fs_path(document.uri) + if tool_path is None: + return [] + targets: list[TextDocument] = [] + for macro_path in imported_macro_paths(Path(tool_path)): + uri = from_fs_path(str(macro_path)) + if uri is None: + continue + if self._workspace is not None: + targets.append(self._workspace.get_text_document(uri)) + else: + targets.append(TextDocument(uri, macro_path.read_text(encoding="utf-8"))) + return targets + + def prepare_rename(self, document: TextDocument, position: Position) -> Range | None: + """The renameable range under *position*, or ``None`` to reject the rename. + + Accepts only when the cursor sits on a real occurrence of a *locally defined* + parameter that the rename can rewrite. Returns ``None`` — the editor shows its + generic "can't be renamed" — when the cursor is not on a renameable occurrence at + all: ``#raw`` / ``##`` comments / ``${SHELL_VAR}`` / ```` text, a name not + defined in this document, or a macro-supplied reference. + + But when the cursor *is* on a parameter defined here that the engine refuses to + rewrite **safely**, raises ``JsonRpcInvalidParams`` with the specific bail reason — + so the user learns *why* it can't be renamed instead of the generic message (the + same human messages ``rename`` reports, surfaced one step earlier). + """ + offset = document.offset_at_position(position) + name = _identifier_at(document.source, offset) + if name is None or name not in _defined_param_names(document.source): + return None + plan = rename_param_plan(document.source, old=name, new=_RENAME_PROBE) + if plan.bailed: + raise _bail_error(name, _RENAME_PROBE, plan.reason) + for edit in plan.edits: + if edit.start <= offset <= edit.end: + return convert_document_offsets_to_range(document, edit.start, edit.end) + return None + + def rename(self, document: TextDocument, position: Position, new_name: str) -> WorkspaceEdit: + """A minimal ``WorkspaceEdit`` renaming the parameter under *position* to *new_name*. + + Raises ``JsonRpcInvalidParams`` with a human message when the rename cannot proceed + (an atomic bail, an invalid new name, or the cursor not being on a parameter). + """ + offset = document.offset_at_position(position) + name = _identifier_at(document.source, offset) + if name is None or name not in _defined_param_names(document.source): + raise JsonRpcInvalidParams(message="Place the cursor on a tool parameter to rename it.") + plan = rename_param_plan(document.source, old=name, new=new_name) + if plan.bailed: + raise _bail_error(name, new_name, plan.reason) + changes = {document.uri: _text_edits(document, plan)} + edited_macros: list[TextDocument] = [] + for macro in self._macro_targets(document): + macro_plan = rename_param_plan(macro.source, old=name, new=new_name) + if macro_plan.bailed: + # The macro does not mention the parameter -> nothing to do for it. Any + # other bail means it references the parameter but cannot be rewritten + # safely, so the whole (atomic) rename is refused rather than half-applied. + if macro_plan.reason == "not-found": + continue + raise _bail_error(name, new_name, macro_plan.reason) + if macro_plan.edits: + changes[macro.uri] = _text_edits(macro, macro_plan) + edited_macros.append(macro) + # Shared-macro gate: refuse rather than silently leave other importers inconsistent. + shared = self._first_shared_macro(edited_macros, document) + if shared is not None: + macro_path, other_tool = shared + raise JsonRpcInvalidParams( + message=( + f"Can't rename '{name}' here: it is referenced in '{macro_path.name}', " + f"a macro shared by other tools (e.g. {other_tool.name}). Renaming it in " + f"the editor would leave those tools referencing '{name}'. Use the " + "galaxy-tool-refactor CLI (rename-param --across-importers) to rename " + "across every importer." + ) + ) + return WorkspaceEdit(changes=changes) + + def find_references(self, document: TextDocument, position: Position) -> list[Location] | None: + """Every occurrence of the parameter under *position*, across the tool and its macros. + + Returns ``None`` when the cursor is not on a renameable parameter. Reuses the rename + plan's edit spans — each covers exactly one occurrence of the name in a file. Macro + files that cannot be rewritten safely are skipped (references are best-effort and + read-only), unlike ``rename`` which bails the whole operation. + """ + offset = document.offset_at_position(position) + name = _identifier_at(document.source, offset) + if name is None or name not in _defined_param_names(document.source): + return None + plan = rename_param_plan(document.source, old=name, new=f"{name}Ref") + if plan.bailed: + return None + locations = [ + Location(uri=document.uri, range=convert_document_offsets_to_range(document, edit.start, edit.end)) + for edit in plan.edits + ] + for macro in self._macro_targets(document): + macro_plan = rename_param_plan(macro.source, old=name, new=f"{name}Ref") + if not macro_plan.bailed: + locations.extend( + Location(uri=macro.uri, range=convert_document_offsets_to_range(macro, edit.start, edit.end)) + for edit in macro_plan.edits + ) + return locations diff --git a/server/galaxyls/tests/unit/test_rename.py b/server/galaxyls/tests/unit/test_rename.py new file mode 100644 index 0000000..038ecf1 --- /dev/null +++ b/server/galaxyls/tests/unit/test_rename.py @@ -0,0 +1,333 @@ +"""Tests for the tool-parameter Rename Symbol / Find References feature (rename.py).""" + +import pytest + +pytest.importorskip("galaxy_tool_source", reason="the optional rename engine is not installed") + +from pathlib import Path # noqa: E402 + +from lsprotocol.types import Position, WorkspaceEdit # noqa: E402 +from pygls.exceptions import JsonRpcInvalidParams # noqa: E402 +from pygls.uris import from_fs_path # noqa: E402 +from pygls.workspace import TextDocument, Workspace # noqa: E402 + +from galaxyls.services.tools.rename import RenameService # noqa: E402 +from galaxyls.tests.unit.utils import TestUtils # noqa: E402 + +# The cursor marker placed inside a source string; removed before parsing. +MARK = "^" + + +def _document_and_position(source_with_mark: str) -> tuple[TextDocument, Position]: + """A (TextDocument, Position) pair from a source string with a single MARK cursor.""" + position, source = TestUtils.extract_mark_from_source(MARK, source_with_mark) + return TestUtils.to_document(source), position + + +def _apply_workspace_edit(document: TextDocument, workspace_edit: WorkspaceEdit) -> str: + """Apply a single-document WorkspaceEdit's TextEdits to the source, highest offset first.""" + assert workspace_edit.changes is not None + edits = workspace_edit.changes[document.uri] + spans = [ + (document.offset_at_position(edit.range.start), document.offset_at_position(edit.range.end), edit.new_text) + for edit in edits + ] + result = document.source + for start, end, new_text in sorted(spans, reverse=True): + result = result[:start] + new_text + result[end:] + return result + + +# --- prepareRename -------------------------------------------------------------- + + +def test_prepare_rename_accepts_on_command_reference() -> None: + document, position = _document_and_position( + "run $o^ld -o out" + ) + rename_range = RenameService().prepare_rename(document, position) + assert rename_range is not None + start = document.offset_at_position(rename_range.start) + end = document.offset_at_position(rename_range.end) + assert document.source[start:end] == "old" + + +def test_prepare_rename_accepts_on_definition() -> None: + document, position = _document_and_position( + "run $old" + ) + assert RenameService().prepare_rename(document, position) is not None + + +@pytest.mark.parametrize( + "source_with_mark", + [ + # Inside a #raw block: a defined param, but this occurrence is not a live reference. + "" + "#raw\nrun $o^ld\n#end raw\nrun $old", + # Inside a ## comment. + "" + "## mentions $o^ld\nrun $old", + # A shell variable, not a defined tool parameter. + "echo ${SHELL_^VAR} $old", + # Not on a word at all (whitespace). + "run $old ^ -o out", + ], +) +def test_prepare_rename_rejects(source_with_mark: str) -> None: + document, position = _document_and_position(source_with_mark) + assert RenameService().prepare_rename(document, position) is None + + +def test_prepare_rename_surfaces_bail_reason_on_a_defined_param() -> None: + # The cursor is on a parameter defined in THIS document, but the rename is unsafe + # (an ambiguous bare reference in an output ): prepareRename raises with the + # specific reason so the editor shows *why*, rather than the generic "can't be + # renamed" a plain None would produce — mirroring what rename() reports. + document, position = _document_and_position( + "run $o^ld" + "old == 'old'" + ) + with pytest.raises(JsonRpcInvalidParams) as excinfo: + RenameService().prepare_rename(document, position) + assert "filter" in str(excinfo.value).lower() + + +# --- rename --------------------------------------------------------------------- + + +def test_rename_rewrites_definition_and_references() -> None: + document, position = _document_and_position( + "" + "run $o^ld $old -o out" + "" + ) + edit = RenameService().rename(document, position, "renamed") + result = _apply_workspace_edit(document, edit) + assert "name='renamed'" in result + assert "$renamed $renamed" in result + assert "${renamed}.txt" in result + assert "old" not in result.replace("name='o'", "") # no stray old (the data name='o' is unrelated) + + +def test_rename_through_multiline_tag_and_entities() -> None: + document, position = _document_and_position( + "\n" + " \n" + "echo a && run $o^ld" + ) + edit = RenameService().rename(document, position, "aligned") + result = _apply_workspace_edit(document, edit) + assert "name='aligned'" in result + assert "&& run $aligned" in result # entities preserved, only the token changed + + +def test_rename_now_rewrites_a_clean_filter_bare_ref() -> None: + # The engine's tokenize-based rewrite (galaxy-tool-source decisions + # §22, shipped after the original pin) renames an unambiguous bare reference + # in an output filter instead of bailing — this case used to raise. + document, position = _document_and_position( + "run $o^ld" + "old == 'x'" + ) + edit = RenameService().rename(document, position, "renamed") + result = _apply_workspace_edit(document, edit) + assert "renamed == 'x'" in result + assert "run $renamed" in result + + +def test_rename_bails_with_message_on_ambiguous_filter_bare_ref() -> None: + # Still-bailing residual: *old* also appears as a string literal in the + # filter (a possible cond['old'] sub-parameter key, indistinguishable from a + # coincidental value), so the engine refuses rather than guess. + document, position = _document_and_position( + "run $o^ld" + "old == 'old'" + ) + with pytest.raises(JsonRpcInvalidParams) as excinfo: + RenameService().rename(document, position, "renamed") + assert "filter" in str(excinfo.value).lower() + + +def test_rename_bails_with_message_on_invalid_new_name() -> None: + document, position = _document_and_position( + "run $o^ld" + ) + with pytest.raises(JsonRpcInvalidParams) as excinfo: + RenameService().rename(document, position, "not a valid name") + assert "not a valid name" in str(excinfo.value) + + +def test_rename_off_word_raises() -> None: + document, position = _document_and_position("run $old ^ end") + with pytest.raises(JsonRpcInvalidParams): + RenameService().rename(document, position, "renamed") + + +# --- references ----------------------------------------------------------------- + + +def test_find_references_returns_all_occurrences() -> None: + document, position = _document_and_position( + "" + "run $o^ld $old" + ) + locations = RenameService().find_references(document, position) + assert locations is not None + # definition name + two command references + assert len(locations) == 3 + for location in locations: + assert location.uri == document.uri + start = document.offset_at_position(location.range.start) + end = document.offset_at_position(location.range.end) + assert document.source[start:end] == "old" + + +def test_find_references_none_off_word() -> None: + document, position = _document_and_position("run $old^ ") + # cursor just after $old resolves to "old" — to get a true None, sit on whitespace + document, position = _document_and_position("run $old ^") + assert RenameService().find_references(document, position) is None + + +# --- cross-file: rename / references span imported macro files ------------------- +# A parameter is defined in the tool but referenced in an imported macro (the real +# pal2nal shape). The rename must follow it into the macro file, or it silently breaks. + +_PAL2NAL_MACROS = ( + "" + "" +) +_PAL2NAL_TOOL = ( + "macros.xml" + "" + "" +) + + +def _bundle(tmp_path: Path, tool_with_mark: str, macros_source: str) -> tuple[TextDocument, Position]: + """Write tool + macros.xml to *tmp_path*; return the tool's (TextDocument, Position).""" + (tmp_path / "macros.xml").write_text(macros_source, encoding="utf-8") + position, source = TestUtils.extract_mark_from_source(MARK, tool_with_mark) + tool_path = tmp_path / "tool.xml" + tool_path.write_text(source, encoding="utf-8") + return TextDocument(from_fs_path(str(tool_path)), source), position + + +def _macro_uri(tmp_path: Path) -> str: + return from_fs_path(str((tmp_path / "macros.xml").resolve())) + + +def _apply_for_uri(uri: str, source: str, workspace_edit: WorkspaceEdit) -> str: + """Apply a WorkspaceEdit's TextEdits for one *uri* to *source*, highest offset first.""" + assert workspace_edit.changes is not None + document = TextDocument(uri, source) + spans = [ + (document.offset_at_position(e.range.start), document.offset_at_position(e.range.end), e.new_text) + for e in workspace_edit.changes.get(uri, []) + ] + result = source + for start, end, new_text in sorted(spans, reverse=True): + result = result[:start] + new_text + result[end:] + return result + + +def test_rename_spans_imported_macro(tmp_path: Path) -> None: + document, position = _bundle(tmp_path, _PAL2NAL_TOOL, _PAL2NAL_MACROS) + edit = RenameService().rename(document, position, "aln") + assert edit.changes is not None + macro_uri = _macro_uri(tmp_path) + assert document.uri in edit.changes and macro_uri in edit.changes # both files edited + # The tool's definition is renamed... + tool_result = _apply_for_uri(document.uri, document.source, edit) + assert "name='aln'" in tool_result + # ...and the reference in the macro file is renamed too. + macro_src = (tmp_path / "macros.xml").read_text(encoding="utf-8") + macro_result = _apply_for_uri(macro_uri, macro_src, edit) + assert "$aln" in macro_result and "protein_alignment" not in macro_result + + +def test_rename_unrelated_macro_is_not_edited(tmp_path: Path) -> None: + macros = "x" + tool = ( + "macros.xml" + "run $old" + ) + document, position = _bundle(tmp_path, tool, macros) + edit = RenameService().rename(document, position, "new") + assert edit.changes is not None + assert _macro_uri(tmp_path) not in edit.changes # the macro never mentions the param + assert document.uri in edit.changes + + +def test_rename_bails_when_macro_reference_is_unsafe(tmp_path: Path) -> None: + # The macro references the param but a #set local shadows it -> the whole rename bails. + macros = ( + "" + "#set $protein_alignment = 1\nrun $protein_alignment" + ) + document, position = _bundle(tmp_path, _PAL2NAL_TOOL, macros) + with pytest.raises(JsonRpcInvalidParams) as excinfo: + RenameService().rename(document, position, "aln") + assert "shadow" in str(excinfo.value).lower() + + +def test_find_references_spans_imported_macro(tmp_path: Path) -> None: + document, position = _bundle(tmp_path, _PAL2NAL_TOOL, _PAL2NAL_MACROS) + locations = RenameService().find_references(document, position) + assert locations is not None + uris = {location.uri for location in locations} + assert document.uri in uris # the definition in the tool + assert _macro_uri(tmp_path) in uris # the reference in the macro + + +# --- shared-macro gate (workspace-aware) ---------------------------------------- +# When a rename would edit a macro that ANOTHER tool in the workspace imports, the +# editor refuses (the counterpart of the CLI's sole-owned gate) rather than silently +# leave the sibling tool referencing the old name. + + +def _open_tool(tmp_path: Path, name: str, source_with_mark: str) -> tuple[TextDocument, Position]: + """Write a marked tool to *name* and return its (TextDocument with a real uri, Position).""" + position, source = TestUtils.extract_mark_from_source(MARK, source_with_mark) + path = tmp_path / name + path.write_text(source, encoding="utf-8") + return TextDocument(from_fs_path(str(path)), source), position + + +def test_rename_refuses_shared_macro_in_workspace(tmp_path: Path) -> None: + (tmp_path / "shared.xml").write_text(_PAL2NAL_MACROS, encoding="utf-8") + tail = ( + "shared.xml" + "" + "" + ) + # b.xml also imports shared.xml -> the macro is shared. + (tmp_path / "b.xml").write_text("" + tail, encoding="utf-8") + a_marked = ( + "shared.xml" + "" + "" + ) + document, position = _open_tool(tmp_path, "a.xml", a_marked) + service = RenameService(Workspace(from_fs_path(str(tmp_path)))) + with pytest.raises(JsonRpcInvalidParams) as excinfo: + service.rename(document, position, "aln") + message = str(excinfo.value) + assert "shared" in message and "b.xml" in message + + +def test_rename_sole_owned_macro_with_workspace_applies(tmp_path: Path) -> None: + # Only the open tool imports the macro -> sole-owned -> the gate does not trip. + (tmp_path / "shared.xml").write_text(_PAL2NAL_MACROS, encoding="utf-8") + a_marked = ( + "shared.xml" + "" + "" + ) + document, position = _open_tool(tmp_path, "a.xml", a_marked) + service = RenameService(Workspace(from_fs_path(str(tmp_path)))) + edit = service.rename(document, position, "aln") + assert edit.changes is not None + assert from_fs_path(str((tmp_path / "shared.xml").resolve())) in edit.changes diff --git a/server/requirements-dev.txt b/server/requirements-dev.txt index 694c3a4..a4618bf 100644 --- a/server/requirements-dev.txt +++ b/server/requirements-dev.txt @@ -10,3 +10,9 @@ pytest-mock==3.15.1 pytest==9.0.3 setuptools==82.0.1 types-setuptools==82.0.0.20260518 + +# Optional engine for the parameter Rename Symbol / Find References feature. It is NOT a +# runtime requirement (kept out of requirements.txt / install_requires); the feature +# self-registers only when it is importable. Users who want the feature install it with: +# pip install galaxy-tool-source +galaxy-tool-source==0.3.3