Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic tool_id search from Tool-shed #82

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions server/gx-workflow-ls-format2/src/languageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}

Expand Down
66 changes: 56 additions & 10 deletions server/gx-workflow-ls-format2/src/services/completionService.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<CompletionList> {
public async doComplete(documentContext: GxFormat2WorkflowDocument, position: Position): Promise<CompletionList> {
const textDocument = documentContext.textDocument;
const nodeManager = documentContext.nodeManager;
const result: CompletionList = {
Expand Down Expand Up @@ -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<string>,
offset: number
): CompletionItem[] {
offset: number,
nodeManager: ASTNodeManager,
node?: ASTNode
): Promise<CompletionItem[]> {
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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 {
Expand Down
78 changes: 75 additions & 3 deletions server/gx-workflow-ls-format2/tests/integration/completion.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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();
});
});
});
23 changes: 20 additions & 3 deletions server/packages/server-common/src/configService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TYPES } from "./languageTypes";
interface ExtensionSettings {
cleaning: CleaningSettings;
validation: ValidationSettings;
toolshed: Toolshed;
}

/** Contains settings for workflow cleaning. */
Expand All @@ -29,13 +30,22 @@ 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"],
},
validation: {
profile: "basic",
},
toolshed: {
url: "https://toolshed.g2.bx.psu.edu",
},
};

let globalSettings: ExtensionSettings = defaultSettings;
Expand All @@ -46,10 +56,12 @@ const documentSettingsCache: Map<string, ExtensionSettings> = new Map();
export interface ConfigService {
readonly connection: Connection;
initialize(capabilities: ClientCapabilities, onConfigurationChanged: () => void): void;
getDocumentSettings(uri: string): Promise<ExtensionSettings>;
getDocumentSettings(uri?: string): Promise<ExtensionSettings>;
onDocumentClose(uri: string): void;
}

const sectionName = "galaxyWorkflows";

@injectable()
export class ConfigServiceImpl implements ConfigService {
protected hasConfigurationCapability = false;
Expand All @@ -67,15 +79,20 @@ export class ConfigServiceImpl implements ConfigService {
this.hasConfigurationCapability = !!(capabilities.workspace && !!capabilities.workspace.configuration);
}

public async getDocumentSettings(uri: string): Promise<ExtensionSettings> {
public async getDocumentSettings(uri?: string): Promise<ExtensionSettings> {
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);
Expand Down
4 changes: 3 additions & 1 deletion server/packages/server-common/src/inversify.config.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigService>(TYPES.ConfigService).to(ConfigServiceImpl).inSingletonScope();
container.bind<DocumentsCache>(TYPES.DocumentsCache).to(DocumentsCacheImpl).inSingletonScope();
container.bind<WorkflowDataProvider>(TYPES.WorkflowDataProvider).to(WorkflowDataProviderImpl).inSingletonScope();
container.bind<ToolshedService>(TYPES.ToolshedService).to(ToolshedServiceImpl).inSingletonScope();

export { container };
23 changes: 23 additions & 0 deletions server/packages/server-common/src/languageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,28 @@ export interface WorkflowDataProvider {
getWorkflowOutputs(workflowDocumentUri: string): Promise<GetWorkflowOutputsResult>;
}

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<ToolInfo[]>;
}

const TYPES = {
DocumentsCache: Symbol.for("DocumentsCache"),
ConfigService: Symbol.for("ConfigService"),
Expand All @@ -307,6 +329,7 @@ const TYPES = {
GalaxyWorkflowLanguageServer: Symbol.for("GalaxyWorkflowLanguageServer"),
WorkflowDataProvider: Symbol.for("WorkflowDataProvider"),
SymbolsProvider: Symbol.for("SymbolsProvider"),
ToolshedService: Symbol.for("ToolshedService"),
};

export { TYPES };
Loading