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