diff --git a/client/package.json b/client/package.json index 418880a..fd5ea1e 100644 --- a/client/package.json +++ b/client/package.json @@ -132,6 +132,22 @@ "category": "Galaxy Tools", "enablement": "galaxytools:isActive", "icon": "$(open-preview)" + }, + { + "command": "galaxytools.insert.paramReference", + "title": "Insert a reference to a param element.", + "category": "Galaxy Tools", + "enablement": "galaxytools:isActive", + "icon": "$(insert)", + "when": "editorTextFocus" + }, + { + "command": "galaxytools.insert.paramFilterReference", + "title": "Insert a reference to a param element to be used as output filter.", + "category": "Galaxy Tools", + "enablement": "galaxytools:isActive", + "icon": "$(insert)", + "when": "editorTextFocus" } ], "keybindings": [ @@ -154,6 +170,16 @@ "command": "galaxytools.sort.documentParamsAttributes", "key": "ctrl+alt+s ctrl+alt+d", "mac": "cmd+alt+s cmd+alt+d" + }, + { + "command": "galaxytools.insert.paramReference", + "key": "ctrl+alt+i ctrl+alt+p", + "mac": "cmd+alt+i cmd+alt+p" + }, + { + "command": "galaxytools.insert.paramFilterReference", + "key": "ctrl+alt+i ctrl+alt+f", + "mac": "cmd+alt+i cmd+alt+f" } ], "configuration": { diff --git a/client/src/commands.ts b/client/src/commands.ts index 31cc4cf..5a30135 100644 --- a/client/src/commands.ts +++ b/client/src/commands.ts @@ -33,6 +33,8 @@ export namespace Commands { export const OPEN_TERMINAL_AT_DIRECTORY_ITEM: ICommand = getCommands("openTerminalAtDirectory"); export const GENERATE_EXPANDED_DOCUMENT: ICommand = getCommands("generate.expandedDocument"); export const PREVIEW_EXPANDED_DOCUMENT: ICommand = getCommands("preview.expandedDocument"); + export const INSERT_PARAM_REFERENCE: ICommand = getCommands("insert.paramReference"); + export const INSERT_PARAM_FILTER_REFERENCE: ICommand = getCommands("insert.paramFilterReference"); } interface GeneratedSnippetResult { @@ -47,6 +49,10 @@ interface ReplaceTextRangeResult { replace_range: Range; } +interface ParamReferencesResult { + references: string[]; +} + export interface GeneratedExpandedDocument { content: string; error_message: string; @@ -65,6 +71,8 @@ export function setupCommands(client: LanguageClient, context: ExtensionContext) setupGenerateExpandedDocument(client, context); + setupInsertParamReference(client, context); + context.subscriptions.push( commands.registerCommand(Commands.PREVIEW_EXPANDED_DOCUMENT.internal, previewExpandedDocument) ); @@ -121,6 +129,15 @@ function setupGenerateTestCases(client: LanguageClient, context: ExtensionContex context.subscriptions.push(commands.registerCommand(Commands.GENERATE_TEST.internal, generateTest)); } +function setupInsertParamReference(client: LanguageClient, context: ExtensionContext) { + context.subscriptions.push(commands.registerCommand(Commands.INSERT_PARAM_REFERENCE.internal, () => { + pickParamReferenceToInsert(client, Commands.INSERT_PARAM_REFERENCE.external); + })); + context.subscriptions.push(commands.registerCommand(Commands.INSERT_PARAM_FILTER_REFERENCE.internal, () => { + pickParamReferenceToInsert(client, Commands.INSERT_PARAM_FILTER_REFERENCE.external); + })) +} + function setupAutoCloseTags(client: LanguageClient, context: ExtensionContext) { const tagProvider = async (document: TextDocument, position: Position) => { let param = client.code2ProtocolConverter.asTextDocumentPositionParams(document, position); @@ -212,6 +229,30 @@ async function requestInsertSnippet(client: LanguageClient, command: string) { } } +async function pickParamReferenceToInsert(client: LanguageClient, command: string, pickerTitle: string = "Select a parameter reference to insert") { + const activeEditor = window.activeTextEditor; + if (!activeEditor) return; + + const document = activeEditor.document; + + const param = client.code2ProtocolConverter.asTextDocumentIdentifier(document); + const response = await commands.executeCommand(command, param); + if (!response || !response.references || response.references.length === 0) { + return; + } + + try { + const selected = await window.showQuickPick(response.references, { title: pickerTitle }); + if (!selected) return; + + activeEditor.edit(editBuilder => { + editBuilder.insert(activeEditor.selection.active, selected); + }); + } catch (err: any) { + window.showErrorMessage(err); + } +} + async function ensureDocumentIsSaved(editor: TextEditor): Promise { if (editor.document.isDirty) { await editor.document.save(); diff --git a/server/galaxyls/constants.py b/server/galaxyls/constants.py index dc608a5..4ec03b4 100644 --- a/server/galaxyls/constants.py +++ b/server/galaxyls/constants.py @@ -15,6 +15,8 @@ class Commands: DISCOVER_TESTS_IN_WORKSPACE = "gls.tests.discoverInWorkspace" DISCOVER_TESTS_IN_DOCUMENT = "gls.tests.discoverInDocument" GENERATE_EXPANDED_DOCUMENT = "gls.generate.expandedDocument" + INSERT_PARAM_REFERENCE = "gls.insert.paramReference" + INSERT_PARAM_FILTER_REFERENCE = "gls.insert.paramFilterReference" class DiagnosticCodes: diff --git a/server/galaxyls/server.py b/server/galaxyls/server.py index 67e0477..9c65543 100644 --- a/server/galaxyls/server.py +++ b/server/galaxyls/server.py @@ -56,6 +56,7 @@ CommandParameters, GeneratedExpandedDocument, GeneratedSnippetResult, + ParamReferencesResult, ReplaceTextRangeResult, TestSuiteInfoResult, ) @@ -298,6 +299,32 @@ def discover_tests_in_document_command( return None +@language_server.command(Commands.INSERT_PARAM_REFERENCE) +async def cmd_insert_param_reference( + server: GalaxyToolsLanguageServer, parameters: CommandParameters +) -> Optional[ParamReferencesResult]: + """Provides a list of possible parameter references to be inserted in the command section of the document.""" + params = convert_to(parameters[0], TextDocumentIdentifier) + document = _get_valid_document(server, params.uri) + if document: + xml_document = _get_xml_document(document) + return server.service.param_references_provider.get_param_command_references(xml_document) + return None + + +@language_server.command(Commands.INSERT_PARAM_FILTER_REFERENCE) +async def cmd_insert_param_filter_reference( + server: GalaxyToolsLanguageServer, parameters: CommandParameters +) -> Optional[ParamReferencesResult]: + """Provides a list of possible parameter references to be inserted as output filters.""" + params = convert_to(parameters[0], TextDocumentIdentifier) + document = _get_valid_document(server, params.uri) + if document: + xml_document = _get_xml_document(document) + return server.service.param_references_provider.get_param_filter_references(xml_document) + return None + + def _validate(server: GalaxyToolsLanguageServer, params) -> None: """Validates the Galaxy tool and reports any problem found.""" diagnostics: List[Diagnostic] = [] diff --git a/server/galaxyls/services/language.py b/server/galaxyls/services/language.py index a0834ce..3abacdb 100644 --- a/server/galaxyls/services/language.py +++ b/server/galaxyls/services/language.py @@ -26,6 +26,7 @@ from galaxyls.services.definitions import DocumentDefinitionsProvider from galaxyls.services.links import DocumentLinksProvider from galaxyls.services.macros import MacroExpanderService +from galaxyls.services.references import ParamReferencesProvider from galaxyls.services.symbols import DocumentSymbolsProvider from galaxyls.services.tools.common import ( TestsDiscoveryService, @@ -78,6 +79,7 @@ def __init__(self) -> None: self.definitions_provider: Optional[DocumentDefinitionsProvider] = None self.link_provider = DocumentLinksProvider() self.symbols_provider = DocumentSymbolsProvider() + self.param_references_provider = ParamReferencesProvider() def set_workspace(self, workspace: Workspace) -> None: macro_definitions_provider = MacroDefinitionsProvider(workspace) diff --git a/server/galaxyls/services/references.py b/server/galaxyls/services/references.py new file mode 100644 index 0000000..38d4912 --- /dev/null +++ b/server/galaxyls/services/references.py @@ -0,0 +1,72 @@ +from typing import Callable, List, Optional + +from galaxyls.services.tools.document import GalaxyToolXmlDocument +from galaxyls.services.xml.document import XmlDocument +from galaxyls.services.xml.nodes import XmlElement +from galaxyls.types import ParamReferencesResult + +ReferenceBuilder = Callable[[XmlElement], Optional[str]] + + +class ParamReferencesProvider: + def get_param_command_references(self, xml_document: XmlDocument) -> Optional[ParamReferencesResult]: + """Returns a list of references for the input parameters of the tool that can be used in the command section.""" + return self._get_param_references(xml_document, self._build_command_reference) + + def get_param_filter_references(self, xml_document: XmlDocument) -> Optional[ParamReferencesResult]: + """Returns a list of references for the input parameters of the tool that can be used in output filters.""" + return self._get_param_references(xml_document, self._build_filter_reference) + + def _get_param_references( + self, xml_document: XmlDocument, reference_builder: ReferenceBuilder + ) -> Optional[ParamReferencesResult]: + tool = GalaxyToolXmlDocument.from_xml_document(xml_document).get_expanded_tool_document() + references = [] + params = tool.get_input_params() + for param in params: + reference = reference_builder(param) + if reference: + references.append(reference) + return ParamReferencesResult(references) + + def _build_command_reference(self, param: XmlElement) -> Optional[str]: + reference = None + path = self._get_param_path(param) + if path: + reference = f"${'.'.join(path)}" + return reference + + def _build_filter_reference(self, param: XmlElement) -> Optional[str]: + reference = None + path = self._get_param_path(param) + if path: + reference = path[0] + for elem in path[1:]: + reference += f"['{elem}']" + return reference + + def _get_param_path(self, param: XmlElement) -> List[str]: + path = [] + # Skip the first 3 ancestors (document root, tool, inputs) to start at the input element. + ancestors = param.ancestors[3:] + for ancestor in ancestors: + name = ancestor.get_attribute_value("name") + if name: + path.append(name) + name = self._get_param_name(param) + if name: + path.append(name) + return path + + def _get_param_name(self, param: XmlElement) -> Optional[str]: + name = param.get_attribute_value("name") + if not name: + name = param.get_attribute_value("argument") + if name: + return self._normalize_argument_name(name) + return name + + def _normalize_argument_name(self, argument: str) -> str: + if argument.startswith("--"): + argument = argument[2:] + return argument.replace("-", "_") diff --git a/server/galaxyls/services/tools/document.py b/server/galaxyls/services/tools/document.py index 779a94f..51139ba 100644 --- a/server/galaxyls/services/tools/document.py +++ b/server/galaxyls/services/tools/document.py @@ -7,10 +7,12 @@ ) from anytree import find # type: ignore +from galaxy.util import xml_macros from lsprotocol.types import ( Position, Range, ) +from lxml import etree from pygls.workspace import Document from galaxyls.services.tools.constants import ( @@ -169,6 +171,17 @@ def get_outputs(self) -> List[XmlElement]: return outputs.elements return [] + def get_input_params(self) -> List[XmlElement]: + """Gets the input params of this document as a list of elements. + + Returns: + List[XmlElement]: The params defined in the document. + """ + inputs = self.find_element(INPUTS) + if inputs: + return inputs.get_recursive_descendants_with_name("param") + return [] + def get_tool_element(self) -> Optional[XmlElement]: """Gets the root tool element""" return self.find_element(TOOL) @@ -217,6 +230,22 @@ def get_import_macro_file_range(self, file_path: Optional[str]) -> Optional[Rang return self.xml_document.get_full_range(imp) return None + def get_expanded_tool_document(self) -> "GalaxyToolXmlDocument": + """If the given tool document uses macros, a new tool document with the expanded macros is returned, + otherwise, the same document is returned. + """ + if self.uses_macros: + try: + document = self.document + expanded_tool_tree, _ = xml_macros.load_with_references(document.path) + expanded_tool_tree = cast(etree._ElementTree, expanded_tool_tree) # type: ignore + expanded_source = etree.tostring(expanded_tool_tree, encoding=str) + expanded_document = Document(uri=document.uri, source=expanded_source, version=document.version) + return GalaxyToolXmlDocument(expanded_document) + except BaseException: + return self + return self + def get_tool_id(self) -> Optional[str]: """Gets the identifier of the tool""" tool_element = self.get_tool_element() diff --git a/server/galaxyls/services/tools/generators/snippets.py b/server/galaxyls/services/tools/generators/snippets.py index 4de62d9..8983452 100644 --- a/server/galaxyls/services/tools/generators/snippets.py +++ b/server/galaxyls/services/tools/generators/snippets.py @@ -10,13 +10,10 @@ cast, ) -from galaxy.util import xml_macros from lsprotocol.types import ( Position, Range, ) -from lxml import etree -from pygls.workspace import Document from galaxyls.services.tools.constants import ( DASH, @@ -32,7 +29,7 @@ class SnippetGenerator(ABC): def __init__(self, tool_document: GalaxyToolXmlDocument, tabSize: int = 4) -> None: self.tool_document = tool_document - self.expanded_document = self._get_expanded_tool_document(tool_document) + self.expanded_document = tool_document.get_expanded_tool_document() self.tabstop_count: int = 0 self.indent_spaces: str = " " * tabSize super().__init__() @@ -63,22 +60,6 @@ def _find_snippet_insert_position(self) -> Union[Position, Range]: snippet will be inserted.""" pass - def _get_expanded_tool_document(self, tool_document: GalaxyToolXmlDocument) -> GalaxyToolXmlDocument: - """If the given tool document uses macros, a new tool document with the expanded macros is returned, - otherwise, the same document is returned. - """ - if tool_document.uses_macros: - try: - document = tool_document.document - expanded_tool_tree, _ = xml_macros.load_with_references(document.path) - expanded_tool_tree = cast(etree._ElementTree, expanded_tool_tree) # type: ignore - expanded_source = etree.tostring(expanded_tool_tree, encoding=str) - expanded_document = Document(uri=document.uri, source=expanded_source, version=document.version) - return GalaxyToolXmlDocument(expanded_document) - except BaseException: - return tool_document - return tool_document - def _get_next_tabstop(self) -> str: """Increments the tabstop count and returns the current tabstop in TextMate format. diff --git a/server/galaxyls/services/xml/nodes.py b/server/galaxyls/services/xml/nodes.py index 994b9f8..f3fc9d1 100644 --- a/server/galaxyls/services/xml/nodes.py +++ b/server/galaxyls/services/xml/nodes.py @@ -421,6 +421,15 @@ def get_children_with_name(self, name: str) -> List["XmlElement"]: children = [child for child in self.children if child.name == name] return list(children) + def get_recursive_descendants_with_name(self, name: str) -> List["XmlElement"]: + descendants = [] + for child in self.children: + if child.name == name: + descendants.append(child) + if isinstance(child, XmlElement): + descendants.extend(child.get_recursive_descendants_with_name(name)) + return descendants + def get_cdata_section(self) -> Optional["XmlCDATASection"]: """Gets the CDATA node inside this element or None if it doesn't have a CDATA section.""" return next((node for node in self.children if type(node) is XmlCDATASection), None) diff --git a/server/galaxyls/types.py b/server/galaxyls/types.py index 1c197cf..fbef4dd 100644 --- a/server/galaxyls/types.py +++ b/server/galaxyls/types.py @@ -88,3 +88,13 @@ class GeneratedExpandedDocument: content: Optional[str] = attrs.field(default=None) error_message: Optional[str] = attrs.field(default=None, alias="errorMessage") + + +class ParamReferencesResult: + """Contains information about the references to a parameter in the document.""" + + def __init__( + self, + references: List[str], + ) -> None: + self.references = references