diff --git a/server/gx-workflow-ls-format2/src/languageService.ts b/server/gx-workflow-ls-format2/src/languageService.ts index d75d993..2e35bdb 100644 --- a/server/gx-workflow-ls-format2/src/languageService.ts +++ b/server/gx-workflow-ls-format2/src/languageService.ts @@ -1,24 +1,24 @@ import { - TextDocument, - Range, + CompletionList, + Diagnostic, FormattingOptions, - TextEdit, + Hover, + LanguageService, LanguageServiceBase, Position, - Hover, - CompletionList, - Diagnostic, + Range, + TextDocument, + TextEdit, WorkflowValidator, - LanguageService, } 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"; +import { inject, injectable } from "inversify"; import { GxFormat2WorkflowDocument } from "./gxFormat2WorkflowDocument"; import { GalaxyWorkflowFormat2SchemaLoader } from "./schema"; import { GxFormat2CompletionService } from "./services/completionService"; import { GxFormat2HoverService } from "./services/hoverService"; import { GxFormat2SchemaValidationService, WorkflowValidationService } from "./services/validation"; -import { inject, injectable } from "inversify"; -import { TYPES as YAML_TYPES } from "@gxwf/yaml-language-service/src/inversify.config"; const LANGUAGE_ID = "gxformat2"; @@ -61,19 +61,18 @@ export class GxFormat2WorkflowLanguageServiceImpl } public override doHover(documentContext: GxFormat2WorkflowDocument, position: Position): Promise { - return this._hoverService.doHover(documentContext.textDocument, position, documentContext.nodeManager); + return this._hoverService.doHover(documentContext, position); } public override async doComplete( documentContext: GxFormat2WorkflowDocument, position: Position ): Promise { - return this._completionService.doComplete(documentContext.textDocument, position, documentContext.nodeManager); + return this._completionService.doComplete(documentContext, position); } protected override async doValidation(documentContext: GxFormat2WorkflowDocument): Promise { - const format2WorkflowDocument = documentContext as GxFormat2WorkflowDocument; - const diagnostics = await this._yamlLanguageService.doValidation(format2WorkflowDocument.yamlDocument); + const diagnostics = await this._yamlLanguageService.doValidation(documentContext.yamlDocument); for (const validator of this._validationServices) { const results = await validator.doValidation(documentContext); diagnostics.push(...results); diff --git a/server/gx-workflow-ls-format2/src/schema/definitions.ts b/server/gx-workflow-ls-format2/src/schema/definitions.ts index db02d71..b05a01e 100644 --- a/server/gx-workflow-ls-format2/src/schema/definitions.ts +++ b/server/gx-workflow-ls-format2/src/schema/definitions.ts @@ -164,6 +164,45 @@ export interface SchemaNode { isRoot: boolean; } +export class EnumSchemaNode implements SchemaNode { + public static definitions: SchemaDefinitions; + + private readonly _schemaEnum: SchemaEnum; + + constructor(schemaEnum: SchemaEnum) { + this._schemaEnum = schemaEnum; + } + + public get name(): string { + return this._schemaEnum.name; + } + + public get symbols(): string[] { + return this._schemaEnum.symbols; + } + + public get documentation(): string | undefined { + return this._schemaEnum.doc; + } + + public get isRoot(): boolean { + return !!this._schemaEnum.documentRoot; + } + + public get canBeArray(): boolean { + return false; + } + + public get typeRef(): string { + return this._schemaEnum.name; + } + + //Override toString for debugging purposes + public toString(): string { + return `EnumSchemaNode: ${this.name} - ${this.symbols}`; + } +} + export class FieldSchemaNode implements SchemaNode, IdMapper { public static definitions: SchemaDefinitions; @@ -281,11 +320,16 @@ export class FieldSchemaNode implements SchemaNode, IdMapper { private isObjectType(typeName: string): boolean { return FieldSchemaNode.definitions.records.has(typeName); } + + //Override toString for debugging purposes + public toString(): string { + return `FieldSchemaNode: ${this.name} - ${this.typeRef}`; + } } export class RecordSchemaNode implements SchemaNode { public static definitions: SchemaDefinitions; - public static readonly NULL: SchemaNode = new RecordSchemaNode({ + public static readonly NULL = new RecordSchemaNode({ name: "null", type: "null", fields: [], @@ -343,11 +387,16 @@ export class RecordSchemaNode implements SchemaNode { public getFieldByName(name: string): FieldSchemaNode | undefined { return this.fields.find((t) => t.name === name); } + + //Override toString for debugging purposes + public toString(): string { + return `RecordSchemaNode: ${this.name}`; + } } export interface SchemaDefinitions { records: Map; - fields: Map; + enums: Map; specializations: Map; primitiveTypes: Set; } diff --git a/server/gx-workflow-ls-format2/src/schema/schemaLoader.ts b/server/gx-workflow-ls-format2/src/schema/schemaLoader.ts index 6ffd7be..276a4ad 100644 --- a/server/gx-workflow-ls-format2/src/schema/schemaLoader.ts +++ b/server/gx-workflow-ls-format2/src/schema/schemaLoader.ts @@ -1,4 +1,5 @@ import { + EnumSchemaNode, FieldSchemaNode, isSchemaEntryBase, isSchemaEnumType, @@ -70,7 +71,7 @@ export class GalaxyWorkflowFormat2SchemaLoader implements GalaxyWorkflowSchemaLo private loadSchemaDefinitions(schemaEntries: Map): SchemaDefinitions { const definitions: SchemaDefinitions = { records: new Map(), - fields: new Map(), + enums: new Map(), specializations: new Map(), primitiveTypes: new Set(), }; @@ -78,20 +79,18 @@ export class GalaxyWorkflowFormat2SchemaLoader implements GalaxyWorkflowSchemaLo this.expandEntries(schemaEntries.values()); schemaEntries.forEach((v, k) => { if (isSchemaRecord(v)) { - definitions.records.set(k, new RecordSchemaNode(v)); + const record = new RecordSchemaNode(v); + definitions.records.set(k, record); if (v.specialize) { v.specialize.forEach((sp) => { definitions.specializations.set(sp.specializeFrom, sp.specializeTo); }); } - v.fields.forEach((field) => { - if (definitions.fields.has(field.name)) { - if (this.enableDebugTrace) console.debug("****** DUPLICATED FIELD", field.name); - } - definitions.fields.set(field.name, new FieldSchemaNode(field)); - }); - } else if (isSchemaEnumType(v) && v.name === "GalaxyType") { - definitions.primitiveTypes = new Set(v.symbols); + } else if (isSchemaEnumType(v)) { + definitions.enums.set(k, new EnumSchemaNode(v)); + if (v.name === "GalaxyType") { + definitions.primitiveTypes = new Set(v.symbols); + } } }); return definitions; diff --git a/server/gx-workflow-ls-format2/src/schema/schemaNodeResolver.ts b/server/gx-workflow-ls-format2/src/schema/schemaNodeResolver.ts index bf2f12d..8450d19 100644 --- a/server/gx-workflow-ls-format2/src/schema/schemaNodeResolver.ts +++ b/server/gx-workflow-ls-format2/src/schema/schemaNodeResolver.ts @@ -1,5 +1,5 @@ import { NodePath, Segment } from "@gxwf/server-common/src/ast/types"; -import { RecordSchemaNode, SchemaDefinitions, SchemaNode, SchemaRecord } from "./definitions"; +import { FieldSchemaNode, RecordSchemaNode, SchemaDefinitions, SchemaNode, SchemaRecord } from "./definitions"; export interface SchemaNodeResolver { rootNode: SchemaNode; @@ -9,7 +9,7 @@ export interface SchemaNodeResolver { } export class SchemaNodeResolverImpl implements SchemaNodeResolver { - public readonly rootNode: SchemaNode; + public readonly rootNode: RecordSchemaNode; constructor( public readonly definitions: SchemaDefinitions, root?: SchemaRecord @@ -17,18 +17,27 @@ export class SchemaNodeResolverImpl implements SchemaNodeResolver { this.rootNode = root ? new RecordSchemaNode(root) : RecordSchemaNode.NULL; } + /** + * Determines the matching schema node for the last segment in the path. + * @param path The path to resolve from root to leaf + * @returns The matching schema node for the last segment in the path or undefined + * if the path does not match any schema node. + */ public resolveSchemaContext(path: NodePath): SchemaNode | undefined { const toWalk = path.slice(); - const lastSegment = toWalk.pop(); - const schemaNodeFound = this.getSchemaNodeForSegment(lastSegment); - while (toWalk.length && !schemaNodeFound) { - const parentSegment = toWalk.pop(); - const parentNode = this.getSchemaNodeForSegment(parentSegment); - if (parentNode) { - return this.getSchemaNodeForSegment(parentNode.typeRef); + let currentSegment = toWalk.shift(); + let currentSchemaNode: SchemaNode | undefined = this.rootNode; + + while (currentSegment !== undefined) { + if (currentSchemaNode instanceof RecordSchemaNode) { + currentSchemaNode = currentSchemaNode.fields.find((f) => f.name === currentSegment); + } else if (currentSchemaNode instanceof FieldSchemaNode) { + const typeNode = this.getSchemaNodeByTypeRef(currentSchemaNode.typeRef); + currentSchemaNode = typeNode; } + currentSegment = toWalk.shift(); } - return schemaNodeFound; + return currentSchemaNode; } public getSchemaNodeByTypeRef(typeRef: string): SchemaNode | undefined { @@ -41,7 +50,7 @@ export class SchemaNodeResolverImpl implements SchemaNodeResolver { if (this.definitions.records.has(pathSegment)) { return this.definitions.records.get(pathSegment); } - return this.definitions.fields.get(pathSegment); + return this.definitions.enums.get(pathSegment); } return undefined; } diff --git a/server/gx-workflow-ls-format2/src/services/completionService.ts b/server/gx-workflow-ls-format2/src/services/completionService.ts index bee39b7..5764eba 100644 --- a/server/gx-workflow-ls-format2/src/services/completionService.ts +++ b/server/gx-workflow-ls-format2/src/services/completionService.ts @@ -1,59 +1,55 @@ -import { ASTNodeManager } from "@gxwf/server-common/src/ast/nodeManager"; import { ASTNode } from "@gxwf/server-common/src/ast/types"; -import { - CompletionItem, - CompletionItemKind, - CompletionList, - Position, - TextDocument, -} from "@gxwf/server-common/src/languageTypes"; +import { CompletionItem, CompletionItemKind, CompletionList, Position } from "@gxwf/server-common/src/languageTypes"; import { TextBuffer } from "@gxwf/yaml-language-service/src/utils/textBuffer"; -import { RecordSchemaNode, SchemaNode, SchemaNodeResolver } from "../schema"; +import { GxFormat2WorkflowDocument } from "../gxFormat2WorkflowDocument"; +import { FieldSchemaNode, RecordSchemaNode, SchemaNode, SchemaNodeResolver } from "../schema"; +import { EnumSchemaNode } from "../schema/definitions"; export class GxFormat2CompletionService { constructor(protected readonly schemaNodeResolver: SchemaNodeResolver) {} - public doComplete( - textDocument: TextDocument, - position: Position, - nodeManager: ASTNodeManager - ): Promise { + public doComplete(documentContext: GxFormat2WorkflowDocument, position: Position): Promise { + const textDocument = documentContext.textDocument; + const nodeManager = documentContext.nodeManager; const result: CompletionList = { items: [], isIncomplete: false, }; - // TODO: Refactor most of this to an Context class with all the information around the cursor const textBuffer = new TextBuffer(textDocument); - const text = textBuffer.getText(); const offset = textBuffer.getOffsetAt(position); - const node = nodeManager.getNodeFromOffset(offset); - if (!node) { - return Promise.resolve(result); - } - if (text.charAt(offset - 1) === ":") { - return Promise.resolve(result); - } - - const currentWord = textBuffer.getCurrentWord(offset); - - DEBUG_printNodeName(node); + let node = nodeManager.getNodeFromOffset(offset); - const existing = nodeManager.getDeclaredPropertyNames(node); - if (nodeManager.isRoot(node)) { - result.items = this.getProposedItems(this.schemaNodeResolver.rootNode, currentWord, existing); - return Promise.resolve(result); - } const nodePath = nodeManager.getPathFromNode(node); - const schemaNode = this.schemaNodeResolver.resolveSchemaContext(nodePath); + let schemaNode = this.schemaNodeResolver.resolveSchemaContext(nodePath); + if (schemaNode === undefined) { + // Try parent node + node = node?.parent; + const parentPath = nodePath.slice(0, -1); + const parentNode = this.schemaNodeResolver.resolveSchemaContext(parentPath); + schemaNode = parentNode; + } if (schemaNode) { - result.items = this.getProposedItems(schemaNode, currentWord, existing); + const existing = nodeManager.getDeclaredPropertyNames(node); + result.items = this.getProposedItems(schemaNode, textBuffer, existing, offset); } return Promise.resolve(result); } - private getProposedItems(schemaNode: SchemaNode, currentWord: string, exclude: Set): CompletionItem[] { + private getProposedItems( + schemaNode: SchemaNode, + textBuffer: TextBuffer, + exclude: Set, + offset: number + ): CompletionItem[] { const result: CompletionItem[] = []; + const currentWord = textBuffer.getCurrentWord(offset); + const overwriteRange = textBuffer.getCurrentWordRange(offset); + const position = textBuffer.getPosition(offset); + const isPositionAfterColon = textBuffer.isPositionAfterToken(position, ":"); if (schemaNode instanceof RecordSchemaNode) { + if (isPositionAfterColon) { + return result; // Do not suggest fields inlined after colon + } schemaNode.fields .filter((f) => f.name.startsWith(currentWord)) .forEach((field) => { @@ -64,15 +60,57 @@ export class GxFormat2CompletionService { sortText: `_${field.name}`, kind: CompletionItemKind.Field, insertText: `${field.name}: `, + textEdit: { + range: overwriteRange, + newText: `${field.name}: `, + }, }; result.push(item); }); + } else if (schemaNode instanceof FieldSchemaNode) { + if (this.schemaNodeResolver.definitions.primitiveTypes.has(schemaNode.typeRef)) { + const defaultValue = String(schemaNode.default ?? ""); + if (defaultValue) { + const item: CompletionItem = { + label: defaultValue, + kind: CompletionItemKind.Value, + documentation: schemaNode.documentation, + insertText: defaultValue, + textEdit: { + range: overwriteRange, + newText: defaultValue, + }, + }; + result.push(item); + return result; + } + } + const schemaRecord = this.schemaNodeResolver.getSchemaNodeByTypeRef(schemaNode.typeRef); + if (schemaRecord instanceof EnumSchemaNode) { + schemaRecord.symbols + .filter((v) => v.startsWith(currentWord)) + .forEach((value) => { + if (exclude.has(value)) return; + const item: CompletionItem = { + label: value, + sortText: `_${value}`, + kind: CompletionItemKind.EnumMember, + documentation: schemaRecord.documentation, + insertText: value, + textEdit: { + range: overwriteRange, + newText: value, + }, + }; + result.push(item); + }); + } } return result; } } -function DEBUG_printNodeName(node: ASTNode): void { +function _DEBUG_printNodeName(node: ASTNode): void { let nodeName = "_root_"; if (node?.type === "property") { nodeName = node.keyNode.value; diff --git a/server/gx-workflow-ls-format2/src/services/hoverService.ts b/server/gx-workflow-ls-format2/src/services/hoverService.ts index 11fd931..f04cea7 100644 --- a/server/gx-workflow-ls-format2/src/services/hoverService.ts +++ b/server/gx-workflow-ls-format2/src/services/hoverService.ts @@ -1,17 +1,15 @@ -import { ASTNodeManager } from "@gxwf/server-common/src/ast/nodeManager"; import { NodePath } from "@gxwf/server-common/src/ast/types"; -import { Hover, MarkupContent, MarkupKind, Position, Range, TextDocument } from "@gxwf/server-common/src/languageTypes"; +import { Hover, MarkupContent, MarkupKind, Position, Range } from "@gxwf/server-common/src/languageTypes"; +import { GxFormat2WorkflowDocument } from "../gxFormat2WorkflowDocument"; import { SchemaNode, SchemaNodeResolver } from "../schema"; export class GxFormat2HoverService { constructor(protected readonly schemaNodeResolver: SchemaNodeResolver) {} //Based on https://github.com/microsoft/vscode-json-languageservice/blob/12275e448a91973777c94a2e5d92c961f281231a/src/services/jsonHover.ts#L23 - public async doHover( - textDocument: TextDocument, - position: Position, - nodeManager: ASTNodeManager - ): Promise { + public async doHover(documentContext: GxFormat2WorkflowDocument, position: Position): Promise { + const textDocument = documentContext.textDocument; + const nodeManager = documentContext.nodeManager; const offset = textDocument.offsetAt(position); let node = nodeManager.getNodeFromOffset(offset); if ( diff --git a/server/gx-workflow-ls-format2/tests/integration/completion.test.ts b/server/gx-workflow-ls-format2/tests/integration/completion.test.ts new file mode 100644 index 0000000..a4421f5 --- /dev/null +++ b/server/gx-workflow-ls-format2/tests/integration/completion.test.ts @@ -0,0 +1,304 @@ +import { CompletionList } from "@gxwf/server-common/src/languageTypes"; +import { 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"; + +describe("Format2 Workflow Completion Service", () => { + let service: GxFormat2CompletionService; + beforeAll(() => { + const schemaNodeResolver = new GalaxyWorkflowFormat2SchemaLoader().nodeResolver; + service = new GxFormat2CompletionService(schemaNodeResolver); + }); + + async function getCompletions( + contents: string, + position: { line: number; character: number } + ): Promise { + const documentContext = createFormat2WorkflowDocument(contents); + + return await service.doComplete(documentContext, position); + } + + it("should suggest all the basic properties of the workflow when the document is empty", async () => { + const template = ` +$`; + const EXPECTED_COMPLETION_LABELS = [ + "class", + "steps", + "report", + "tags", + "creator", + "license", + "release", + "inputs", + "outputs", + "id", + "label", + "doc", + "uuid", + ]; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + const completionLabels = getCompletionItemsLabels(completions); + expect(completionLabels).toEqual(EXPECTED_COMPLETION_LABELS); + }); + + it("should suggest the `class` property if the word starts with `cl`", async () => { + const template = ` +cl$`; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + expect(completions?.items.length).toBe(1); + expect(completions?.items[0].label).toBe("class"); + }); + + it("should suggest the available classes for the `class` property", async () => { + const template = ` +class: $`; + const EXPECTED_COMPLETION_LABELS = ["GalaxyWorkflow"]; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + const completionLabels = getCompletionItemsLabels(completions); + expect(completionLabels).toEqual(EXPECTED_COMPLETION_LABELS); + }); + + it("should suggest the basic properties of the workflow that are not already defined", async () => { + const template = ` +class: GalaxyWorkflow +id: my_workflow +doc: This is a simple workflow +$`; + const EXPECTED_COMPLETION_LABELS = [ + "steps", + "report", + "tags", + "creator", + "license", + "release", + "inputs", + "outputs", + "label", + "uuid", + ]; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + const completionLabels = getCompletionItemsLabels(completions); + expect(completionLabels).toEqual(EXPECTED_COMPLETION_LABELS); + }); + + it("should suggest the `inputs` property if the word starts with `in`", async () => { + const template = ` +class: GalaxyWorkflow +in$ +`; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + expect(completions?.items.length).toBe(1); + expect(completions?.items[0].label).toBe("inputs"); + }); + + it("should not suggest property completions inlined with the definition", async () => { + const template = ` +class: GalaxyWorkflow +inputs: + My input:$`; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + expect(completions?.items).toHaveLength(0); + }); + + it("should suggest the properties of a workflow input", async () => { + const template = ` +class: GalaxyWorkflow +inputs: + My input: + $`; + const EXPECTED_COMPLETION_LABELS = [ + "type", + "optional", + "format", + "collection_type", + "default", + "label", + "doc", + "id", + "position", + ]; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + const completionLabels = getCompletionItemsLabels(completions); + expect(completionLabels).toEqual(EXPECTED_COMPLETION_LABELS); + }); + + it("should suggest the `type` property if the word starts with `t`", async () => { + const template = ` +class: GalaxyWorkflow +inputs: + My input: + t$`; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + expect(completions?.items.length).toBe(1); + expect(completions?.items[0].label).toBe("type"); + }); + + it("should suggest the available types for the `type` property", async () => { + const template = ` + class: GalaxyWorkflow + inputs: + My input: + type: $`; + const EXPECTED_COMPLETION_LABELS = [ + "integer", + "text", + "File", + "data", + "collection", + "null", + "boolean", + "int", + "long", + "float", + "double", + "string", + ]; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + const completionLabels = getCompletionItemsLabels(completions); + expect(completionLabels).toEqual(EXPECTED_COMPLETION_LABELS); + }); + + it("should suggest the correct input properties when the cursor is inside a property of a particular type", async () => { + const template = ` +class: GalaxyWorkflow +inputs: + My input: + type: File + $`; + const EXPECTED_COMPLETION_LABELS = [ + "optional", + "format", + "collection_type", + "default", + "label", + "doc", + "id", + "position", + ]; + const EXPECTED_EXISITING_PROPERTIES = ["type"]; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + const completionLabels = completions?.items.map((item) => item.label) ?? []; + expect(completionLabels).toEqual(EXPECTED_COMPLETION_LABELS); + expect(completionLabels).not.toContain(EXPECTED_EXISITING_PROPERTIES); + }); + + it("should suggest the correct input properties when there are other inputs defined after the cursor", async () => { + const template = ` +class: GalaxyWorkflow +inputs: + My input: + $ + Another input: + `; + const EXPECTED_COMPLETION_LABELS = [ + "type", + "optional", + "format", + "collection_type", + "default", + "label", + "doc", + "id", + "position", + ]; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + const completionLabels = getCompletionItemsLabels(completions); + expect(completionLabels).toEqual(EXPECTED_COMPLETION_LABELS); + }); + + it("should not suggest anythig when we are defining a workflow input", async () => { + const template = ` +class: GalaxyWorkflow +inputs: + My$`; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + expect(completions?.items).toHaveLength(0); + }); + + it("should not suggest anythig when we are defining a workflow input and there are other inputs defined after the cursor", async () => { + const template = ` +class: GalaxyWorkflow +inputs: + My$ + Another input: + `; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + expect(completions?.items).toHaveLength(0); + }); + + it("should suggest expected fields starting with `s` for a workflow step when there are other fields defined before the cursor", async () => { + const template = ` +class: GalaxyWorkflow +steps: + my_step: + tool: my_tool + s$`; + const EXPECTED_COMPLETION_LABELS = ["state"]; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + const completionLabels = getCompletionItemsLabels(completions); + expect(completionLabels).toEqual(EXPECTED_COMPLETION_LABELS); + }); + + it("should suggest the list of available types for a step", async () => { + const template = ` +class: GalaxyWorkflow +steps: + my_step: + tool: my_tool + type: $ +outputs:`; + const EXPECTED_COMPLETION_LABELS = ["tool", "subworkflow", "pause"]; + const { contents, position } = parseTemplate(template); + + const completions = await getCompletions(contents, position); + + const completionLabels = getCompletionItemsLabels(completions); + expect(completionLabels).toEqual(EXPECTED_COMPLETION_LABELS); + }); +}); diff --git a/server/gx-workflow-ls-format2/tests/integration/schema.test.ts b/server/gx-workflow-ls-format2/tests/integration/schema.test.ts index f8dacda..4946ed3 100644 --- a/server/gx-workflow-ls-format2/tests/integration/schema.test.ts +++ b/server/gx-workflow-ls-format2/tests/integration/schema.test.ts @@ -1,5 +1,10 @@ import { NodePath } from "@gxwf/server-common/src/ast/types"; -import { GalaxyWorkflowFormat2SchemaLoader, RecordSchemaNode, SchemaNodeResolver } from "../../src/schema"; +import { + FieldSchemaNode, + GalaxyWorkflowFormat2SchemaLoader, + RecordSchemaNode, + SchemaNodeResolver, +} from "../../src/schema"; describe("Gxformat2 Schema Handling", () => { describe("Schema Version 19_09", () => { @@ -37,6 +42,37 @@ describe("Gxformat2 Schema Handling", () => { expect(schemaNode).toBeDefined(); expect(schemaNode?.name).toBe(expectedNodeName); }); + + it("returns undefined for unknown path", () => { + const schemaNode = nodeResolver.resolveSchemaContext(["unknown"]); + expect(schemaNode).toBeUndefined(); + }); + + it("returns the correct schema node from a path pointing to a field", () => { + const schemaNode = nodeResolver.resolveSchemaContext(["inputs", "input1", "type"]); + expect(schemaNode).toBeDefined(); + expect(schemaNode instanceof FieldSchemaNode).toBe(true); + expect((schemaNode as FieldSchemaNode).name).toBe("type"); + expect((schemaNode as FieldSchemaNode).default).toBe("data"); + expect((schemaNode as FieldSchemaNode).canBeAny).toBe(false); + }); + }); + + describe("getSchemaNodeByTypeRef", () => { + it.each([ + ["GalaxyWorkflow", "GalaxyWorkflow"], + ["WorkflowInputParameter", "WorkflowInputParameter"], + ["WorkflowOutputParameter", "WorkflowOutputParameter"], + ["WorkflowStep", "WorkflowStep"], + ["StepPosition", "StepPosition"], + ["ToolShedRepository", "ToolShedRepository"], + ["Report", "Report"], + ["Any", "Any"], + ])("returns expected schema node name from type ref", (typeRef: string, expectedNodeName: string) => { + const schemaNode = nodeResolver.getSchemaNodeByTypeRef(typeRef); + expect(schemaNode).toBeDefined(); + expect(schemaNode?.name).toBe(expectedNodeName); + }); }); }); diff --git a/server/packages/server-common/src/ast/nodeManager.ts b/server/packages/server-common/src/ast/nodeManager.ts index f567453..e9bbdd6 100644 --- a/server/packages/server-common/src/ast/nodeManager.ts +++ b/server/packages/server-common/src/ast/nodeManager.ts @@ -77,14 +77,22 @@ export class ASTNodeManager { return node.children ?? []; } - public getDeclaredPropertyNames(node: ASTNode): Set { - const declaredNodes = this.getChildren(node); + public getDeclaredPropertyNames(node?: ASTNode): Set { const result = new Set(); + if (!node) { + return result; + } + const declaredNodes = this.getChildren(node); declaredNodes.forEach((node) => { if (node.type === "property") { const key = node.keyNode.value; result.add(key); } + if (node.type === "object") { + node.properties.forEach((p) => { + result.add(p.keyNode.value); + }); + } }); return result; } @@ -109,9 +117,9 @@ export class ASTNodeManager { return result; } - public getPathFromNode(node: ASTNode): NodePath { + public getPathFromNode(node?: ASTNode): NodePath { const path: NodePath = []; - let current: ASTNode | undefined = node; + let current = node; while (current) { const segment = this.getNodeSegment(current); if (segment) { diff --git a/server/packages/server-common/src/providers/symbolsProvider.ts b/server/packages/server-common/src/providers/symbolsProvider.ts index bec53c7..1a7ae15 100644 --- a/server/packages/server-common/src/providers/symbolsProvider.ts +++ b/server/packages/server-common/src/providers/symbolsProvider.ts @@ -1,10 +1,10 @@ import { ASTNode, ObjectASTNode, PropertyASTNode } from "../ast/types"; import { - DocumentSymbolParams, - DocumentSymbol, - SymbolKind, DocumentContext, + DocumentSymbol, + DocumentSymbolParams, GalaxyWorkflowLanguageServer, + SymbolKind, } from "../languageTypes"; import { Provider } from "./provider"; @@ -156,7 +156,7 @@ export class SymbolsProvider extends Provider { } private getKeyLabel(property: PropertyASTNode): string { - let name = property.keyNode.value; + let name = String(property.keyNode.value); if (name) { name = name.replace(/[\n]/g, "↵"); } diff --git a/server/packages/server-common/tests/testHelpers.ts b/server/packages/server-common/tests/testHelpers.ts index b8239dc..019143e 100644 --- a/server/packages/server-common/tests/testHelpers.ts +++ b/server/packages/server-common/tests/testHelpers.ts @@ -1,11 +1,30 @@ import { ASTNode, PropertyASTNode } from "../src/ast/types"; -import { WorkflowDataProvider, WorkflowInput, WorkflowOutput } from "../src/languageTypes"; +import { + CompletionItem, + CompletionList, + WorkflowDataProvider, + WorkflowInput, + WorkflowOutput, +} from "../src/languageTypes"; export function expectPropertyNodeToHaveKey(propertyNode: ASTNode | null, expectedPropertyKey: string): void { expect(propertyNode?.type).toBe("property"); expect((propertyNode as PropertyASTNode).keyNode.value).toBe(expectedPropertyKey); } +export function expectCompletionItemDocumentationToContain(completionItem: CompletionItem, value: string): void { + expect(completionItem.documentation).toBeDefined(); + if (typeof completionItem.documentation === "string") { + expect(completionItem.documentation).toContain(value); + } else { + expect(completionItem.documentation?.value).toContain(value); + } +} + +export function getCompletionItemsLabels(completionItems?: CompletionList | null): string[] { + return completionItems?.items.map((item) => item.label) ?? []; +} + /** * Simulates the position of the cursor in the contents of a text document. * @param template Represents the contents of a text document with a single character to be replaced. diff --git a/server/packages/workflow-tests-language-service/src/services/completion/helper.ts b/server/packages/workflow-tests-language-service/src/services/completion/helper.ts index 2e6c922..1784bcb 100644 --- a/server/packages/workflow-tests-language-service/src/services/completion/helper.ts +++ b/server/packages/workflow-tests-language-service/src/services/completion/helper.ts @@ -95,7 +95,7 @@ ${this.indentation}${this.indentation}$0 return completionItem; } - public async doComplete(documentContext: DocumentContext, position: Position): Promise { + public async doComplete(documentContext: DocumentContext, position: Position): Promise { const result = CompletionList.create([], false); const document = documentContext.textDocument; diff --git a/server/packages/workflow-tests-language-service/tests/unit/completion.test.ts b/server/packages/workflow-tests-language-service/tests/unit/completion.test.ts index 08266ca..1d3090a 100644 --- a/server/packages/workflow-tests-language-service/tests/unit/completion.test.ts +++ b/server/packages/workflow-tests-language-service/tests/unit/completion.test.ts @@ -10,6 +10,7 @@ import { EXPECTED_WORKFLOW_OUTPUTS, FAKE_DATASET_INPUT, FAKE_WORKFLOW_DATA_PROVIDER, + expectCompletionItemDocumentationToContain, parseTemplate, } from "@gxwf/server-common/tests/testHelpers"; import { WorkflowTestsLanguageServiceContainerModule } from "@gxwf/workflow-tests-language-service/src/inversify.config"; @@ -31,7 +32,7 @@ describe("Workflow Tests Completion Service", () => { contents: string, position: { line: number; character: number }, workflowDataProvider: WorkflowDataProvider = FAKE_WORKFLOW_DATA_PROVIDER - ): Promise { + ): Promise { const documentContext = createGxWorkflowTestsDocument(contents, workflowDataProvider); return await helper.doComplete(documentContext, position); @@ -43,7 +44,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); expect(completions?.items.length).toBe(1); expect(completions?.items[0].labelDetails?.detail).toBe("New Workflow Test"); @@ -56,7 +56,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); expect(completions?.items.length).toBe(1); expect(completions?.items[0].labelDetails?.detail).toBe("New Workflow Test"); @@ -69,7 +68,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); expect(completions?.items.length).toBe(1); expect(completions?.items[0].labelDetails?.detail).toBe("New Workflow Test"); @@ -85,7 +83,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); expect(completions?.items.length).toBe(2); for (const completionItem of completions!.items) { expect(expectedLabels).toContain(completionItem.label); @@ -100,7 +97,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); const jobCompletion = completions!.items[0]!; expect(jobCompletion.label).toBe("job"); }); @@ -115,7 +111,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); expect(completions?.items.length).toBe(EXPECTED_WORKFLOW_INPUTS.length); for (let index = 0; index < EXPECTED_WORKFLOW_INPUTS.length; index++) { const workflowInput = EXPECTED_WORKFLOW_INPUTS[index]; @@ -133,7 +128,13 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); + const completionItem = completions!.items.find((item) => item.label.startsWith("Input")); + expect(completionItem).toBeDefined(); + expect(completionItem?.insertText).toBeDefined(); + const insertText = completionItem!.insertText!; + expect(insertText.startsWith("'")).toBeTruthy(); + expect(insertText.endsWith(":")).toBeTruthy(); + expect(insertText.lastIndexOf("'")).toBe(insertText.length - 2); }); it("should not suggest an existing input when suggesting inputs", async () => { @@ -148,7 +149,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); expect(completions?.items.length).toBe(expectedNumOfRemainingInputs); const existingTestInput = completions?.items.find((item) => item.label === existingInput.name); expect(existingTestInput).toBeUndefined(); @@ -167,7 +167,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); expect(completions?.items.length).toBe(3); for (const completionItem of completions!.items) { expect(completionItem.label).toContain("class"); @@ -190,7 +189,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); for (const expectedAttribute of expectedAttributes) { const completionItem = completions?.items.find((item) => item.label === expectedAttribute); expect(completionItem).toBeDefined(); @@ -207,7 +205,6 @@ describe("Workflow Tests Completion Service", () => { const completions = await getCompletions(contents, position); - expect(completions).not.toBeNull(); expect(completions?.items.length).toBe(EXPECTED_WORKFLOW_OUTPUTS.length); for (let index = 0; index < EXPECTED_WORKFLOW_OUTPUTS.length; index++) { const workflowOutput = EXPECTED_WORKFLOW_OUTPUTS[index]; @@ -219,15 +216,6 @@ describe("Workflow Tests Completion Service", () => { }); }); -function expectCompletionItemDocumentationToContain(completionItem: CompletionItem, value: string): void { - expect(completionItem.documentation).toBeDefined(); - if (typeof completionItem.documentation === "string") { - expect(completionItem.documentation).toContain(value); - } else { - expect(completionItem.documentation?.value).toContain(value); - } -} - function expectCompletionItemToMatchWorkflowInput(completionItem: CompletionItem, workflowInput: WorkflowInput): void { expect(completionItem.label).toEqual(workflowInput.name); expectCompletionItemDocumentationToContain(completionItem, workflowInput.doc); diff --git a/server/packages/yaml-language-service/src/parser/yamlDocument.ts b/server/packages/yaml-language-service/src/parser/yamlDocument.ts index d12c164..78dc7fe 100644 --- a/server/packages/yaml-language-service/src/parser/yamlDocument.ts +++ b/server/packages/yaml-language-service/src/parser/yamlDocument.ts @@ -89,10 +89,19 @@ export class YAMLDocument implements ParsedDocument { const indentation = this._textBuffer.getLineIndentationAtOffset(offset); const lineContent = this._textBuffer.getLineContent(position.line); const contentAfterCursor = lineContent.slice(position.character).replace(/\s/g, ""); - if (indentation === 0 && contentAfterCursor.length === 0) return rootNode; + const hasColon = lineContent.includes(":"); + if (indentation === 0 && contentAfterCursor.length === 0 && !hasColon) return rootNode; + const isPositionAfterColon = this._textBuffer.isPositionAfterToken(position, ":"); + if (isPositionAfterColon) { + // If the cursor is after a colon, we want to return the node in the same line + const indexBeforeColon = lineContent.lastIndexOf(":"); + const offsetBeforeColon = this._textBuffer.getOffsetAt(Position.create(position.line, indexBeforeColon)); + const node = rootNode.getNodeFromOffsetEndInclusive(offsetBeforeColon); + return node; + } let result = rootNode.getNodeFromOffsetEndInclusive(offset); const parent = this.findParentNodeByIndentation(offset, indentation); - if (!result || (parent && result.offset < parent.offset && result.length > parent.length)) { + if (!result || (parent && result.offset <= parent.offset && result.length > parent.length)) { result = parent; } return result; diff --git a/server/packages/yaml-language-service/src/utils/textBuffer.ts b/server/packages/yaml-language-service/src/utils/textBuffer.ts index 1977f0d..b29896b 100644 --- a/server/packages/yaml-language-service/src/utils/textBuffer.ts +++ b/server/packages/yaml-language-service/src/utils/textBuffer.ts @@ -76,6 +76,15 @@ export class TextBuffer { return text.substring(i + 1, offset); } + public getCurrentWordRange(offset: number): Range { + let i = offset - 1; + const text = this.getText(); + while (i >= 0 && ' \t\n\r\v":{[,]}'.indexOf(text.charAt(i)) === -1) { + i--; + } + return Range.create(this.getPosition(i + 1), this.getPosition(offset)); + } + public hasTextAfterPosition(position: Position): boolean { const lineContent = this.getLineContent(position.line); return lineContent.charAt(position.character + 1).trim() !== ""; @@ -104,4 +113,13 @@ export class TextBuffer { } return currentLine; } + + public isPositionAfterToken(position: Position, token: string): boolean { + const lineContent = this.getLineContent(position.line); + const tokenIndex = lineContent.lastIndexOf(token, position.character); + if (tokenIndex === -1) { + return false; // No token found + } + return tokenIndex < position.character; + } } diff --git a/server/packages/yaml-language-service/tests/unit/textBuffer.test.ts b/server/packages/yaml-language-service/tests/unit/textBuffer.test.ts new file mode 100644 index 0000000..8e520f9 --- /dev/null +++ b/server/packages/yaml-language-service/tests/unit/textBuffer.test.ts @@ -0,0 +1,65 @@ +import { parseTemplate } from "@gxwf/server-common/tests/testHelpers"; +import { TextBuffer } from "../../src/utils/textBuffer"; +import { toTextDocument } from "../testHelper"; + +describe("TextBuffer", () => { + function createTextBuffer(contents: string): TextBuffer { + return new TextBuffer(toTextDocument(contents)); + } + + describe("getLineCount", () => { + it("should return the number of lines in the document", () => { + const contents = `line 1 +line 2 +line 3`; + const textBuffer = createTextBuffer(contents); + + expect(textBuffer.getLineCount()).toBe(3); + }); + }); + + describe("getLineLength", () => { + it("should return the length of the line", () => { + const contents = `line 1 +line 2 +line 3`; + const textBuffer = createTextBuffer(contents); + + // +1 for the newline character + expect(textBuffer.getLineLength(0)).toBe(7); + expect(textBuffer.getLineLength(1)).toBe(7); + expect(textBuffer.getLineLength(2)).toBe(6); + }); + }); + + describe("getLineContent", () => { + it("should return the content of the line", () => { + const contents = `line 1 +line 2 +line 3`; + const textBuffer = createTextBuffer(contents); + + expect(textBuffer.getLineContent(0)).toBe("line 1\n"); + expect(textBuffer.getLineContent(1)).toBe("line 2\n"); + expect(textBuffer.getLineContent(2)).toBe("line 3"); + }); + }); + + describe("isPositionAfterToken", () => { + const TOKEN = ":"; + it.each([ + ["$test:", false], + ["te$st:", false], + ["test$:", false], + ["test:$", true], + ["test: $", true], + ["test: $", true], + ["test: $ ", true], + ["test: $test", true], + ])("returns expected result", (template: string, result: boolean) => { + const { contents, position } = parseTemplate(template); + const textBuffer = createTextBuffer(contents); + expect(textBuffer.isPositionAfterToken(position, TOKEN)).toBe(result); + }); + }); +}); diff --git a/server/packages/yaml-language-service/tests/unit/yamlParser.test.ts b/server/packages/yaml-language-service/tests/unit/yamlParser.test.ts index 87d2776..963378e 100644 --- a/server/packages/yaml-language-service/tests/unit/yamlParser.test.ts +++ b/server/packages/yaml-language-service/tests/unit/yamlParser.test.ts @@ -177,14 +177,16 @@ test: value test02: prop03: test04: val + test05: `; it.each([ [0, "_root_"], // _root_ is not a property, is an object [1, "test"], - [12, "_root_"], + [7, "test"], + [12, "test"], [13, "test02"], - [20, "_root_"], + [20, "test02"], [22, "test02"], [23, "prop03"], [30, "prop03"], @@ -193,8 +195,12 @@ test02: [46, "test04"], [47, "_root_"], [49, "test02"], - [51, "prop03"], - [53, "test04"], + [51, "test05"], + [59, "test05"], + [60, "_root_"], + [62, "test02"], + [64, "prop03"], + [66, "test05"], ])("should return for offset %p the expected node name %p", (offset: number, expectedNodeName: string) => { const parsedDocument = parse(SAMPLE_DOC); const node = parsedDocument.getNodeFromOffset(offset); diff --git a/workflow-languages/schemas/gxformat2/v19_09/workflows.yaml b/workflow-languages/schemas/gxformat2/v19_09/workflows.yaml index 7f50924..0feeb5f 100644 --- a/workflow-languages/schemas/gxformat2/v19_09/workflows.yaml +++ b/workflow-languages/schemas/gxformat2/v19_09/workflows.yaml @@ -362,6 +362,8 @@ $graph: "_id": "@type" "_type": "@vocab" type: string + # This default is a hack and is not included in the official schema. + default: "GalaxyWorkflow" - name: steps doc: { $include: ../common/steps_description.txt } type: