diff --git a/package.json b/package.json index 7a14de0..0b9b0fb 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,12 @@ "Stricter validation to comply with the `Intergalactic Workflow Commission` best practices." ], "default": "basic" + }, + "galaxyWorkflows.toolshed.url": { + "markdownDescription": "The URL of the Galaxy Toolshed to use for tool resolution.", + "scope": "resource", + "type": "string", + "default": "https://toolshed.g2.bx.psu.edu" } } } diff --git a/server/gx-workflow-ls-format2/src/languageService.ts b/server/gx-workflow-ls-format2/src/languageService.ts index f923c97..3f71cd4 100644 --- a/server/gx-workflow-ls-format2/src/languageService.ts +++ b/server/gx-workflow-ls-format2/src/languageService.ts @@ -12,6 +12,7 @@ import { TYPES, TextDocument, TextEdit, + ToolshedService, } from "@gxwf/server-common/src/languageTypes"; import { TYPES as YAML_TYPES } from "@gxwf/yaml-language-service/src/inversify.config"; import { YAMLLanguageService } from "@gxwf/yaml-language-service/src/yamlLanguageService"; @@ -44,13 +45,14 @@ export class GxFormat2WorkflowLanguageServiceImpl constructor( @inject(YAML_TYPES.YAMLLanguageService) yamlLanguageService: YAMLLanguageService, - @inject(TYPES.SymbolsProvider) private symbolsProvider: SymbolsProvider + @inject(TYPES.SymbolsProvider) private symbolsProvider: SymbolsProvider, + @inject(TYPES.ToolshedService) private toolshedService: ToolshedService ) { super(LANGUAGE_ID); this._schemaLoader = new GalaxyWorkflowFormat2SchemaLoader(); this._yamlLanguageService = yamlLanguageService; this._hoverService = new GxFormat2HoverService(this._schemaLoader.nodeResolver); - this._completionService = new GxFormat2CompletionService(this._schemaLoader.nodeResolver); + this._completionService = new GxFormat2CompletionService(this._schemaLoader.nodeResolver, this.toolshedService); this._schemaValidationService = new GxFormat2SchemaValidationService(this._schemaLoader.nodeResolver); } diff --git a/server/gx-workflow-ls-format2/src/services/completionService.ts b/server/gx-workflow-ls-format2/src/services/completionService.ts index 1b53c7e..0f28bdb 100644 --- a/server/gx-workflow-ls-format2/src/services/completionService.ts +++ b/server/gx-workflow-ls-format2/src/services/completionService.ts @@ -1,5 +1,14 @@ +import { ASTNodeManager } from "@gxwf/server-common/src/ast/nodeManager"; import { ASTNode } from "@gxwf/server-common/src/ast/types"; -import { CompletionItem, CompletionItemKind, CompletionList, Position } from "@gxwf/server-common/src/languageTypes"; +import { + CompletionItem, + CompletionItemKind, + CompletionList, + Position, + Range, + ToolInfo, + ToolshedService, +} from "@gxwf/server-common/src/languageTypes"; import { TextBuffer } from "@gxwf/yaml-language-service/src/utils/textBuffer"; import { GxFormat2WorkflowDocument } from "../gxFormat2WorkflowDocument"; import { FieldSchemaNode, RecordSchemaNode, SchemaNode, SchemaNodeResolver } from "../schema"; @@ -12,9 +21,12 @@ export class GxFormat2CompletionService { */ private readonly ignoredSchemaRefs = new Set(["InputParameter", "OutputParameter", "WorkflowStep"]); - constructor(protected readonly schemaNodeResolver: SchemaNodeResolver) {} + constructor( + protected readonly schemaNodeResolver: SchemaNodeResolver, + protected readonly toolshedService: ToolshedService + ) {} - public doComplete(documentContext: GxFormat2WorkflowDocument, position: Position): Promise { + public async doComplete(documentContext: GxFormat2WorkflowDocument, position: Position): Promise { const textDocument = documentContext.textDocument; const nodeManager = documentContext.nodeManager; const result: CompletionList = { @@ -42,20 +54,22 @@ export class GxFormat2CompletionService { } if (schemaNode) { const existing = nodeManager.getDeclaredPropertyNames(node); - result.items = this.getProposedItems(schemaNode, textBuffer, existing, offset); + result.items = await this.getProposedItems(schemaNode, textBuffer, existing, offset, nodeManager, node); } return Promise.resolve(result); } - private getProposedItems( + private async getProposedItems( schemaNode: SchemaNode, textBuffer: TextBuffer, exclude: Set, - offset: number - ): CompletionItem[] { + offset: number, + nodeManager: ASTNodeManager, + node?: ASTNode + ): Promise { const result: CompletionItem[] = []; const currentWord = textBuffer.getCurrentWord(offset); - const overwriteRange = textBuffer.getCurrentWordRange(offset); + let overwriteRange = textBuffer.getCurrentWordRange(offset); const position = textBuffer.getPosition(offset); const isPositionAfterColon = textBuffer.isPositionAfterToken(position, ":"); if (schemaNode instanceof EnumSchemaNode) { @@ -116,11 +130,28 @@ export class GxFormat2CompletionService { result.push(item); return result; } + if ( + schemaNode.name === "tool_id" && + node && + node.type === "property" && + node.valueNode && + node.valueNode.type === "string" + ) { + const searchTerm = node.valueNode.value; + overwriteRange = nodeManager.getNodeRange(node.valueNode); + if (searchTerm && !searchTerm.includes("/")) { + const tools = await this.toolshedService.searchToolsById(searchTerm); + for (const tool of tools) { + const item: CompletionItem = this.buildCompletionItemFromTool(tool, overwriteRange); + result.push(item); + } + } + } } else if (schemaNode.isUnionType) { for (const typeRef of schemaNode.typeRefs) { const typeNode = this.schemaNodeResolver.getSchemaNodeByTypeRef(typeRef); if (typeNode === undefined) continue; - result.push(...this.getProposedItems(typeNode, textBuffer, exclude, offset)); + result.push(...(await this.getProposedItems(typeNode, textBuffer, exclude, offset, nodeManager, node))); } return result; } @@ -131,11 +162,26 @@ export class GxFormat2CompletionService { const schemaRecord = this.schemaNodeResolver.getSchemaNodeByTypeRef(schemaNode.typeRef); if (schemaRecord) { - return this.getProposedItems(schemaRecord, textBuffer, exclude, offset); + return this.getProposedItems(schemaRecord, textBuffer, exclude, offset, nodeManager, node); } } return result; } + + private buildCompletionItemFromTool(tool: ToolInfo, overwriteRange: Range): CompletionItem { + const toolEntry = tool.url.replace("https://", ""); + const item: CompletionItem = { + label: tool.id, + kind: CompletionItemKind.Value, + documentation: tool.description, + insertText: toolEntry, + textEdit: { + range: overwriteRange, + newText: toolEntry, + }, + }; + return item; + } } function _DEBUG_printNodeName(node: ASTNode): void { diff --git a/server/gx-workflow-ls-format2/tests/integration/completion.test.ts b/server/gx-workflow-ls-format2/tests/integration/completion.test.ts index d5b7f16..a14e7be 100644 --- a/server/gx-workflow-ls-format2/tests/integration/completion.test.ts +++ b/server/gx-workflow-ls-format2/tests/integration/completion.test.ts @@ -1,16 +1,22 @@ -import { CompletionList } from "@gxwf/server-common/src/languageTypes"; -import { getCompletionItemsLabels, parseTemplate } from "@gxwf/server-common/tests/testHelpers"; +import { CompletionList, ToolshedService } from "@gxwf/server-common/src/languageTypes"; +import { buildFakeToolInfoList, getCompletionItemsLabels, parseTemplate } from "@gxwf/server-common/tests/testHelpers"; import "reflect-metadata"; import { GalaxyWorkflowFormat2SchemaLoader } from "../../src/schema"; import { GxFormat2CompletionService } from "../../src/services/completionService"; import { createFormat2WorkflowDocument } from "../testHelpers"; +const searchToolsByIdMock = jest.fn(); + +const ToolshedServiceMock: ToolshedService = { + searchToolsById: searchToolsByIdMock, +}; + describe("Format2 Workflow Completion Service", () => { let service: GxFormat2CompletionService; beforeAll(() => { const schemaNodeResolver = new GalaxyWorkflowFormat2SchemaLoader().nodeResolver; - service = new GxFormat2CompletionService(schemaNodeResolver); + service = new GxFormat2CompletionService(schemaNodeResolver, ToolshedServiceMock); }); async function getCompletions( @@ -380,4 +386,70 @@ inputs: expect(completions?.items).toHaveLength(0); }); + + describe("Toolshed tool suggestions", () => { + beforeEach(() => { + searchToolsByIdMock.mockReset(); + searchToolsByIdMock.mockResolvedValue([]); + }); + + it("should suggest toolshed tools when the cursor is inside the `tool_id` property and there is at least one character", async () => { + const expectedTools = buildFakeToolInfoList([{ id: "tool1" }, { id: "tool2" }, { id: "tool3" }]); + searchToolsByIdMock.mockResolvedValue(expectedTools); + + const template = ` +class: GalaxyWorkflow +steps: + my_step: + tool_id: t$`; + const expectedLabels = expectedTools.map((tool) => tool.id); + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + expect(searchToolsByIdMock).toHaveBeenCalledWith("t"); + + const completionLabels = getCompletionItemsLabels(completions); + expect(completionLabels).toEqual(expectedLabels); + }); + + it("should try to search for tools using the full value of `tool_id`", async () => { + const template = ` +class: GalaxyWorkflow +steps: + my_step: + tool_id: search for this$`; + const { contents, position } = parseTemplate(template); + + await getCompletions(contents, position); + + expect(searchToolsByIdMock).toHaveBeenCalledWith("search for this"); + }); + + it("should not try to search tools when the value in `tool_id` is empty", async () => { + const template = ` +class: GalaxyWorkflow +steps: + my_step: + tool_id: $`; + const { contents, position } = parseTemplate(template); + + await getCompletions(contents, position); + + expect(searchToolsByIdMock).not.toHaveBeenCalled(); + }); + + it("should not try to search tools when the value in `tool_id` contains slashes", async () => { + const template = ` +class: GalaxyWorkflow +steps: + my_step: + tool_id: toolshed/owner/repo/tool$`; + const { contents, position } = parseTemplate(template); + + await getCompletions(contents, position); + + expect(searchToolsByIdMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/packages/server-common/src/configService.ts b/server/packages/server-common/src/configService.ts index e22a096..6d1e5d2 100644 --- a/server/packages/server-common/src/configService.ts +++ b/server/packages/server-common/src/configService.ts @@ -11,6 +11,7 @@ import { TYPES } from "./languageTypes"; interface ExtensionSettings { cleaning: CleaningSettings; validation: ValidationSettings; + toolshed: Toolshed; } /** Contains settings for workflow cleaning. */ @@ -29,6 +30,12 @@ interface ValidationSettings { profile: "basic" | "iwc"; } +/** Contains settings for the Toolshed service. */ +interface Toolshed { + /** The URL of the Toolshed to fetch information about tools. */ + url: string; +} + const defaultSettings: ExtensionSettings = { cleaning: { cleanableProperties: ["position", "uuid", "errors", "version"], @@ -36,6 +43,9 @@ const defaultSettings: ExtensionSettings = { validation: { profile: "basic", }, + toolshed: { + url: "https://toolshed.g2.bx.psu.edu", + }, }; let globalSettings: ExtensionSettings = defaultSettings; @@ -46,10 +56,12 @@ const documentSettingsCache: Map = new Map(); export interface ConfigService { readonly connection: Connection; initialize(capabilities: ClientCapabilities, onConfigurationChanged: () => void): void; - getDocumentSettings(uri: string): Promise; + getDocumentSettings(uri?: string): Promise; onDocumentClose(uri: string): void; } +const sectionName = "galaxyWorkflows"; + @injectable() export class ConfigServiceImpl implements ConfigService { protected hasConfigurationCapability = false; @@ -67,15 +79,20 @@ export class ConfigServiceImpl implements ConfigService { this.hasConfigurationCapability = !!(capabilities.workspace && !!capabilities.workspace.configuration); } - public async getDocumentSettings(uri: string): Promise { + public async getDocumentSettings(uri?: string): Promise { if (!this.hasConfigurationCapability) { return Promise.resolve(globalSettings); } + + if (!uri) { + return await this.connection.workspace.getConfiguration(sectionName); + } + let result = documentSettingsCache.get(uri); if (!result) { result = await this.connection.workspace.getConfiguration({ scopeUri: uri, - section: "galaxyWorkflows", + section: sectionName, }); result = result || globalSettings; this.addToDocumentConfigCache(uri, result); diff --git a/server/packages/server-common/src/inversify.config.ts b/server/packages/server-common/src/inversify.config.ts index fce9f36..ff2379b 100644 --- a/server/packages/server-common/src/inversify.config.ts +++ b/server/packages/server-common/src/inversify.config.ts @@ -1,12 +1,14 @@ import { Container } from "inversify"; import { ConfigService, ConfigServiceImpl } from "./configService"; -import { DocumentsCache, TYPES, WorkflowDataProvider } from "./languageTypes"; +import { DocumentsCache, TYPES, ToolshedService, WorkflowDataProvider } from "./languageTypes"; import { DocumentsCacheImpl } from "./models/documentsCache"; import { WorkflowDataProviderImpl } from "./providers/workflowDataProvider"; +import { ToolshedServiceImpl } from "./services/toolShed"; const container = new Container(); container.bind(TYPES.ConfigService).to(ConfigServiceImpl).inSingletonScope(); container.bind(TYPES.DocumentsCache).to(DocumentsCacheImpl).inSingletonScope(); container.bind(TYPES.WorkflowDataProvider).to(WorkflowDataProviderImpl).inSingletonScope(); +container.bind(TYPES.ToolshedService).to(ToolshedServiceImpl).inSingletonScope(); export { container }; diff --git a/server/packages/server-common/src/languageTypes.ts b/server/packages/server-common/src/languageTypes.ts index 3c1fd1c..361c68c 100644 --- a/server/packages/server-common/src/languageTypes.ts +++ b/server/packages/server-common/src/languageTypes.ts @@ -298,6 +298,28 @@ export interface WorkflowDataProvider { getWorkflowOutputs(workflowDocumentUri: string): Promise; } +export interface ToolInfo { + id: string; + name: string; + description: string; + owner: string; + repository: string; + url: string; +} + +/** + * Interface for a service that can provide information about tools from the Toolshed. + */ +export interface ToolshedService { + /** + * Searches for tools by their approximate ID. + * @param toolId The ID of the tool to search for. Doesn't have to be an exact match. + * @param limit The maximum number of tools to return. + * @returns A list of tools that match the search criteria. + */ + searchToolsById(toolId: string): Promise; +} + const TYPES = { DocumentsCache: Symbol.for("DocumentsCache"), ConfigService: Symbol.for("ConfigService"), @@ -307,6 +329,7 @@ const TYPES = { GalaxyWorkflowLanguageServer: Symbol.for("GalaxyWorkflowLanguageServer"), WorkflowDataProvider: Symbol.for("WorkflowDataProvider"), SymbolsProvider: Symbol.for("SymbolsProvider"), + ToolshedService: Symbol.for("ToolshedService"), }; export { TYPES }; diff --git a/server/packages/server-common/src/services/toolShed.ts b/server/packages/server-common/src/services/toolShed.ts new file mode 100644 index 0000000..354049e --- /dev/null +++ b/server/packages/server-common/src/services/toolShed.ts @@ -0,0 +1,96 @@ +import { inject, injectable } from "inversify"; +import { ConfigService } from "../configService"; +import { TYPES, ToolInfo, ToolshedService } from "../languageTypes"; +import { getResponseErrorMessage } from "../utils"; + +interface ToolShedResponse { + total_results: string; + page: string; + page_size: string; + hits: { + tool: { + id: string; + repo_owner_username: string; + repo_name: string; + name: string; + description: string; + }; + matched_terms: { + name?: string; + description?: string; + help?: string; + }; + score: number; + }[]; + hostname: string; +} + +interface BuildRequestResult { + request: Request; + baseUrl: URL; +} + +@injectable() +export class ToolshedServiceImpl implements ToolshedService { + constructor(@inject(TYPES.ConfigService) public readonly configService: ConfigService) {} + + public async searchToolsById(toolId: string, limit = 5): Promise { + const { request, baseUrl } = await this.buildToolSearchRequest(toolId, limit); + const toolshedUrl = baseUrl.origin; + try { + const response = await fetch(request); + + if (!response.ok) { + const error = await getResponseErrorMessage(response); + console.error(`Error fetching tools from the toolshed at '${toolshedUrl}'`, error); + return []; + } + + const json = await response.json(); + const toolshedResponse = json as ToolShedResponse; + const hits = toolshedResponse.hits; + + return hits.map((hit) => { + const tool = hit.tool; + return { + id: tool.id, + name: tool.name, + description: tool.description, + owner: tool.repo_owner_username, + repository: tool.repo_name, + url: `${toolshedUrl}/repos/${tool.repo_owner_username}/${tool.repo_name}/${tool.id}`, + }; + }); + } catch (error) { + console.error(`Error fetching tools from the toolshed at '${toolshedUrl}'`, error); + return []; + } + } + + private async buildToolSearchRequest(toolId: string, limit: number): Promise { + const toolshedUrl = await this.validateToolshedUrl(); + const toolsApiUrl = `${toolshedUrl}/api/tools`; + const queryParams = new URLSearchParams({ + q: toolId, + page_size: limit.toString(), + }); + + return { + request: new Request(`${toolsApiUrl}?${queryParams}`), + baseUrl: toolshedUrl, + }; + } + + private async validateToolshedUrl(): Promise { + const settings = await this.configService.getDocumentSettings(); + let validatedUrl: URL; + try { + validatedUrl = new URL(settings.toolshed.url); + } catch { + throw new Error( + `Invalid Toolshed URL: '${settings.toolshed.url}'. Please provide a valid URL for the setting 'galaxyWorkflows.toolshed.url'.` + ); + } + return validatedUrl; + } +} diff --git a/server/packages/server-common/src/utils.ts b/server/packages/server-common/src/utils.ts index 741d709..4d5143b 100644 --- a/server/packages/server-common/src/utils.ts +++ b/server/packages/server-common/src/utils.ts @@ -57,3 +57,18 @@ export function isSimpleType(type?: string): boolean { } return SIMPLE_TYPES.includes(type); } + +/** + * Extract the error message from a fetch response. + * @param response The fetch response. + * @returns The error message. + */ +export async function getResponseErrorMessage(response: Response): Promise { + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + const json = await response.json(); + return JSON.stringify(json) || response.statusText; + } + const text = await response.text(); + return text || response.statusText; +} diff --git a/server/packages/server-common/tests/testHelpers.ts b/server/packages/server-common/tests/testHelpers.ts index 3ac5e32..78d3b78 100644 --- a/server/packages/server-common/tests/testHelpers.ts +++ b/server/packages/server-common/tests/testHelpers.ts @@ -2,6 +2,7 @@ import { ASTNode, PropertyASTNode } from "../src/ast/types"; import { CompletionItem, CompletionList, + ToolInfo, WorkflowDataProvider, WorkflowInput, WorkflowOutput, @@ -127,3 +128,24 @@ export const FAKE_WORKFLOW_DATA_PROVIDER: WorkflowDataProvider = { }; }, }; + +interface PartialToolInfo extends Partial { + id: string; // Only the id is required +} + +export function buildFakeToolInfo(tool: PartialToolInfo): ToolInfo { + const owner = tool.owner ?? "fakeowner"; + const repository = tool.repository ?? "fakerepo"; + return { + id: tool.id, + name: tool.name ?? `Tool ${tool.id}`, + description: tool.description ?? `This is a tool description for tool ${tool.id}.`, + owner: owner, + repository: repository, + url: `https://toolshed.testing.fake/repos/${owner}/${repository}/${tool.id}`, + }; +} + +export function buildFakeToolInfoList(tools: PartialToolInfo[]): ToolInfo[] { + return tools.map((tool) => buildFakeToolInfo(tool)); +}