diff --git a/server/gx-workflow-ls-format2/src/inversify.config.ts b/server/gx-workflow-ls-format2/src/inversify.config.ts index e84c1a2..b8f7729 100644 --- a/server/gx-workflow-ls-format2/src/inversify.config.ts +++ b/server/gx-workflow-ls-format2/src/inversify.config.ts @@ -1,10 +1,15 @@ import { container } from "@gxwf/server-common/src/inversify.config"; -import { GxFormat2WorkflowLanguageServiceImpl } from "./languageService"; -import { WorkflowTestsLanguageServiceContainerModule } from "@gxwf/workflow-tests-language-service/src/inversify.config"; -import { GalaxyWorkflowLanguageServer, WorkflowLanguageService } from "@gxwf/server-common/src/languageTypes"; +import { + TYPES as COMMON_TYPES, + GalaxyWorkflowLanguageServer, + SymbolsProvider, + WorkflowLanguageService, +} from "@gxwf/server-common/src/languageTypes"; import { GalaxyWorkflowLanguageServerImpl } from "@gxwf/server-common/src/server"; -import { TYPES as COMMON_TYPES } from "@gxwf/server-common/src/languageTypes"; +import { WorkflowTestsLanguageServiceContainerModule } from "@gxwf/workflow-tests-language-service/src/inversify.config"; import { YAMLLanguageServiceContainerModule } from "@gxwf/yaml-language-service/src/inversify.config"; +import { GxFormat2WorkflowLanguageServiceImpl } from "./languageService"; +import { GxFormat2WorkflowSymbolsProvider } from "./services/symbols"; export const TYPES = { ...COMMON_TYPES, @@ -23,4 +28,6 @@ container .to(GalaxyWorkflowLanguageServerImpl) .inSingletonScope(); +container.bind(TYPES.SymbolsProvider).to(GxFormat2WorkflowSymbolsProvider).inSingletonScope(); + export { container }; diff --git a/server/gx-workflow-ls-format2/src/languageService.ts b/server/gx-workflow-ls-format2/src/languageService.ts index 2e35bdb..edef90a 100644 --- a/server/gx-workflow-ls-format2/src/languageService.ts +++ b/server/gx-workflow-ls-format2/src/languageService.ts @@ -1,12 +1,15 @@ import { CompletionList, Diagnostic, + DocumentSymbol, FormattingOptions, Hover, LanguageService, LanguageServiceBase, Position, Range, + SymbolsProvider, + TYPES, TextDocument, TextEdit, WorkflowValidator, @@ -39,7 +42,10 @@ export class GxFormat2WorkflowLanguageServiceImpl private _completionService: GxFormat2CompletionService; private _validationServices: WorkflowValidator[]; - constructor(@inject(YAML_TYPES.YAMLLanguageService) yamlLanguageService: YAMLLanguageService) { + constructor( + @inject(YAML_TYPES.YAMLLanguageService) yamlLanguageService: YAMLLanguageService, + @inject(TYPES.SymbolsProvider) private symbolsProvider: SymbolsProvider + ) { super(LANGUAGE_ID); this._schemaLoader = new GalaxyWorkflowFormat2SchemaLoader(); this._yamlLanguageService = yamlLanguageService; @@ -79,4 +85,8 @@ export class GxFormat2WorkflowLanguageServiceImpl } return diagnostics; } + + public override getSymbols(documentContext: GxFormat2WorkflowDocument): DocumentSymbol[] { + return this.symbolsProvider.getSymbols(documentContext); + } } diff --git a/server/gx-workflow-ls-format2/src/services/symbols.ts b/server/gx-workflow-ls-format2/src/services/symbols.ts new file mode 100644 index 0000000..bb87465 --- /dev/null +++ b/server/gx-workflow-ls-format2/src/services/symbols.ts @@ -0,0 +1,21 @@ +import { SymbolKind } from "@gxwf/server-common/src/languageTypes"; +import { SymbolsProviderBase } from "@gxwf/server-common/src/providers/symbolsProvider"; +import { injectable } from "inversify"; + +@injectable() +export class GxFormat2WorkflowSymbolsProvider extends SymbolsProviderBase { + constructor() { + super(); + this.stepContainerNames = new Set(["inputs", "outputs", "steps"]); + } + + protected override getSymbolKind(nodeType: string): SymbolKind { + switch (nodeType) { + case "doc": + return SymbolKind.String; + case "path": + return SymbolKind.File; + } + return super.getSymbolKind(nodeType); + } +} diff --git a/server/gx-workflow-ls-format2/tests/integration/symbols.test.ts b/server/gx-workflow-ls-format2/tests/integration/symbols.test.ts new file mode 100644 index 0000000..d51028f --- /dev/null +++ b/server/gx-workflow-ls-format2/tests/integration/symbols.test.ts @@ -0,0 +1,50 @@ +import { DocumentSymbol } from "@gxwf/server-common/src/languageTypes"; + +import "reflect-metadata"; +import { GxFormat2WorkflowSymbolsProvider } from "../../src/services/symbols"; +import { createFormat2WorkflowDocument } from "../testHelpers"; + +describe("Format2 Workflow Symbols Provider", () => { + let provider: GxFormat2WorkflowSymbolsProvider; + beforeAll(() => { + provider = new GxFormat2WorkflowSymbolsProvider(); + }); + + function getSymbols(contents: string): DocumentSymbol[] { + const documentContext = createFormat2WorkflowDocument(contents); + return provider.getSymbols(documentContext); + } + + it("should return symbols for a workflow", () => { + const content = ` +class: GalaxyWorkflow +inputs: + input_1: data + input_2: + type: File + doc: This is the input 2 + the_collection: + type: collection + doc: This is a collection + input_int: integer + text_param: + optional: true + default: text value + restrictOnConnections: true + type: text + `; + const symbols = getSymbols(content); + expect(symbols.length).toBe(2); + const classSymbol = symbols[0]; + expect(classSymbol.name).toBe("class"); + expect(classSymbol.detail).toBe("GalaxyWorkflow"); + const inputsSymbol = symbols[1]; + expect(inputsSymbol.name).toBe("inputs"); + expect(inputsSymbol.children?.length).toBe(5); + expect(inputsSymbol.children?.at(0)?.name).toBe("input_1"); + expect(inputsSymbol.children?.at(1)?.name).toBe("input_2"); + expect(inputsSymbol.children?.at(2)?.name).toBe("the_collection"); + expect(inputsSymbol.children?.at(3)?.name).toBe("input_int"); + expect(inputsSymbol.children?.at(4)?.name).toBe("text_param"); + }); +}); diff --git a/server/gx-workflow-ls-native/src/inversify.config.ts b/server/gx-workflow-ls-native/src/inversify.config.ts index 2312ea0..e94dacb 100644 --- a/server/gx-workflow-ls-native/src/inversify.config.ts +++ b/server/gx-workflow-ls-native/src/inversify.config.ts @@ -1,10 +1,10 @@ import { container } from "@gxwf/server-common/src/inversify.config"; -import { NativeWorkflowLanguageServiceImpl } from "./languageService"; -import { WorkflowTestsLanguageServiceContainerModule } from "@gxwf/workflow-tests-language-service/src/inversify.config"; -import { GalaxyWorkflowLanguageServer, WorkflowLanguageService } from "@gxwf/server-common/src/languageTypes"; +import { GalaxyWorkflowLanguageServer, TYPES, WorkflowLanguageService } from "@gxwf/server-common/src/languageTypes"; import { GalaxyWorkflowLanguageServerImpl } from "@gxwf/server-common/src/server"; -import { TYPES } from "@gxwf/server-common/src/languageTypes"; +import { WorkflowTestsLanguageServiceContainerModule } from "@gxwf/workflow-tests-language-service/src/inversify.config"; import { YAMLLanguageServiceContainerModule } from "@gxwf/yaml-language-service/src/inversify.config"; +import { NativeWorkflowLanguageServiceImpl } from "./languageService"; +import { NativeWorkflowSymbolsProvider } from "./services/symbols"; container.load(YAMLLanguageServiceContainerModule); container.load(WorkflowTestsLanguageServiceContainerModule); @@ -19,4 +19,9 @@ container .to(GalaxyWorkflowLanguageServerImpl) .inSingletonScope(); +container + .bind(TYPES.SymbolsProvider) + .to(NativeWorkflowSymbolsProvider) + .inSingletonScope(); + export { container }; diff --git a/server/gx-workflow-ls-native/src/languageService.ts b/server/gx-workflow-ls-native/src/languageService.ts index c8c9284..47cb073 100644 --- a/server/gx-workflow-ls-native/src/languageService.ts +++ b/server/gx-workflow-ls-native/src/languageService.ts @@ -1,27 +1,30 @@ -import { - DocumentLanguageSettings, - getLanguageService, - JSONSchema, - LanguageService as JSONLanguageService, - LanguageServiceParams, - LanguageSettings, - SchemaConfiguration, -} from "vscode-json-languageservice"; import { CompletionList, Diagnostic, + DocumentSymbol, FormattingOptions, Hover, + LanguageService, + LanguageServiceBase, Position, Range, + SymbolsProvider, + TYPES, TextDocument, TextEdit, - LanguageServiceBase, - LanguageService, } from "@gxwf/server-common/src/languageTypes"; +import { inject, injectable } from "inversify"; +import { + DocumentLanguageSettings, + LanguageService as JSONLanguageService, + JSONSchema, + LanguageServiceParams, + LanguageSettings, + SchemaConfiguration, + getLanguageService, +} from "vscode-json-languageservice"; import NativeWorkflowSchema from "../../../workflow-languages/schemas/native.schema.json"; import { NativeWorkflowDocument } from "./nativeWorkflowDocument"; -import { injectable } from "inversify"; const LANGUAGE_ID = "galaxyworkflow"; @@ -39,7 +42,7 @@ export class NativeWorkflowLanguageServiceImpl private _jsonLanguageService: JSONLanguageService; private _documentSettings: DocumentLanguageSettings = { schemaValidation: "error" }; - constructor() { + constructor(@inject(TYPES.SymbolsProvider) private symbolsProvider: SymbolsProvider) { super(LANGUAGE_ID); const params: LanguageServiceParams = {}; const settings = this.getLanguageSettings(); @@ -94,6 +97,10 @@ export class NativeWorkflowLanguageServiceImpl return schemaValidationResults; } + public override getSymbols(documentContext: NativeWorkflowDocument): DocumentSymbol[] { + return this.symbolsProvider.getSymbols(documentContext); + } + private getLanguageSettings(): LanguageSettings { const settings: LanguageSettings = { schemas: [this.getWorkflowSchemaConfig()], diff --git a/server/gx-workflow-ls-native/src/services/symbols.ts b/server/gx-workflow-ls-native/src/services/symbols.ts new file mode 100644 index 0000000..445dd25 --- /dev/null +++ b/server/gx-workflow-ls-native/src/services/symbols.ts @@ -0,0 +1,26 @@ +import { SymbolsProviderBase } from "@gxwf/server-common/src/providers/symbolsProvider"; +import { PropertyASTNode } from "@gxwf/yaml-language-service/src/parser/astTypes"; +import { injectable } from "inversify"; + +@injectable() +export class NativeWorkflowSymbolsProvider extends SymbolsProviderBase { + constructor() { + super(); + this.symbolNamesToIgnore = new Set([ + "a_galaxy_workflow", + "position", + "uuid", + "errors", + "format-version", + "version", + ]); + this.stepContainerNames = new Set(["steps"]); + } + + protected getSymbolName(property: PropertyASTNode): string { + if (this.isStepProperty(property)) { + return this.getNodeName(property.valueNode) ?? "unnamed"; + } + return super.getSymbolName(property); + } +} diff --git a/server/gx-workflow-ls-native/tests/unit/symbols.test.ts b/server/gx-workflow-ls-native/tests/unit/symbols.test.ts new file mode 100644 index 0000000..7938944 --- /dev/null +++ b/server/gx-workflow-ls-native/tests/unit/symbols.test.ts @@ -0,0 +1,43 @@ +import { NativeWorkflowSymbolsProvider } from "../../src/services/symbols"; +import { createNativeWorkflowDocument } from "../testHelpers"; +import { TestWorkflowProvider } from "../testWorkflowProvider"; + +describe("Native Format Symbols Provider", () => { + let provider: NativeWorkflowSymbolsProvider; + + beforeEach(() => { + provider = new NativeWorkflowSymbolsProvider(); + }); + + it("should not provide symbols that must be ignored", () => { + const ignoredSymbols = new Set(["a_galaxy_workflow", "position", "format-version", "version"]); + const wfContent = TestWorkflowProvider.workflows.validation.withThreeSteps; + const wfDocument = createNativeWorkflowDocument(wfContent); + // The ignored nodes exist in the document + ignoredSymbols.forEach((ignoredSymbol) => { + const ignoredSymbolExists = wfContent.includes(ignoredSymbol); + expect(ignoredSymbolExists).toBeTruthy(); + }); + // but they should not be included in the symbols + const symbols = provider.getSymbols(wfDocument); + expect(symbols).not.toBeNull(); + symbols.forEach((symbol) => { + expect(ignoredSymbols.has(symbol.name)).toBeFalsy(); + }); + }); + + it("should provide symbols for all steps with names", () => { + const wfContent = TestWorkflowProvider.workflows.validation.withThreeSteps; + const wfDocument = createNativeWorkflowDocument(wfContent); + const symbols = provider.getSymbols(wfDocument); + expect(symbols).not.toBeNull(); + const stepsSymbol = symbols.find((symbol) => symbol.name === "steps"); + expect(stepsSymbol).toBeDefined(); + expect(stepsSymbol?.children).toBeDefined(); + expect(stepsSymbol?.children?.length).toBe(3); + const stepNames = ["Input dataset", "Input dataset", "Concatenate datasets"]; + stepsSymbol?.children?.forEach((step, i) => { + expect(step.name).toBe(stepNames[i]); + }); + }); +}); diff --git a/server/packages/server-common/src/languageTypes.ts b/server/packages/server-common/src/languageTypes.ts index fd3b3cd..7e49115 100644 --- a/server/packages/server-common/src/languageTypes.ts +++ b/server/packages/server-common/src/languageTypes.ts @@ -180,6 +180,10 @@ export interface DocumentContext { internalDocument: unknown; } +export interface SymbolsProvider { + getSymbols(documentContext: DocumentContext): DocumentSymbol[]; +} + export interface LanguageService { readonly languageId: string; @@ -187,6 +191,7 @@ export interface LanguageService { format(document: TextDocument, range: Range, options: FormattingOptions): TextEdit[]; doHover(documentContext: T, position: Position): Promise; doComplete(documentContext: T, position: Position): Promise; + getSymbols(documentContext: T): DocumentSymbol[]; /** * Validates the document and reports all the diagnostics found. @@ -204,13 +209,13 @@ export interface LanguageService { @injectable() export abstract class LanguageServiceBase implements LanguageService { constructor(@unmanaged() public readonly languageId: string) {} - protected server?: GalaxyWorkflowLanguageServer; public abstract parseDocument(document: TextDocument): T; public abstract format(document: TextDocument, range: Range, options: FormattingOptions): TextEdit[]; public abstract doHover(documentContext: T, position: Position): Promise; public abstract doComplete(documentContext: T, position: Position): Promise; + public abstract getSymbols(documentContext: T): DocumentSymbol[]; /** Performs basic syntax and semantic validation based on the document schema. */ protected abstract doValidation(documentContext: T): Promise; @@ -270,6 +275,7 @@ const TYPES = { WorkflowTestsLanguageService: Symbol.for("WorkflowTestsLanguageService"), GalaxyWorkflowLanguageServer: Symbol.for("GalaxyWorkflowLanguageServer"), WorkflowDataProvider: Symbol.for("WorkflowDataProvider"), + SymbolsProvider: Symbol.for("SymbolsProvider"), }; export { TYPES }; diff --git a/server/packages/server-common/src/providers/completionProvider.ts b/server/packages/server-common/src/providers/completionHandler.ts similarity index 67% rename from server/packages/server-common/src/providers/completionProvider.ts rename to server/packages/server-common/src/providers/completionHandler.ts index 0bca9a4..81ed162 100644 --- a/server/packages/server-common/src/providers/completionProvider.ts +++ b/server/packages/server-common/src/providers/completionHandler.ts @@ -1,15 +1,11 @@ import { CompletionList, CompletionParams } from "vscode-languageserver"; -import { Provider } from "./provider"; import { GalaxyWorkflowLanguageServer } from "../languageTypes"; +import { ServerEventHandler } from "./handler"; -export class CompletionProvider extends Provider { - public static register(server: GalaxyWorkflowLanguageServer): CompletionProvider { - return new CompletionProvider(server); - } - +export class CompletionHandler extends ServerEventHandler { constructor(server: GalaxyWorkflowLanguageServer) { super(server); - this.server.connection.onCompletion(async (params) => this.onCompletion(params)); + this.register(this.server.connection.onCompletion(async (params) => this.onCompletion(params))); } private async onCompletion(params: CompletionParams): Promise { diff --git a/server/packages/server-common/src/providers/formattingProvider.ts b/server/packages/server-common/src/providers/formattingHandler.ts similarity index 75% rename from server/packages/server-common/src/providers/formattingProvider.ts rename to server/packages/server-common/src/providers/formattingHandler.ts index 2f4de4d..9226356 100644 --- a/server/packages/server-common/src/providers/formattingProvider.ts +++ b/server/packages/server-common/src/providers/formattingHandler.ts @@ -1,24 +1,20 @@ import { - FormattingOptions, - TextDocument, - TextEdit, - Position, - Range, DocumentFormattingParams, DocumentRangeFormattingParams, + FormattingOptions, GalaxyWorkflowLanguageServer, + Position, + Range, + TextDocument, + TextEdit, } from "../languageTypes"; -import { Provider } from "./provider"; - -export class FormattingProvider extends Provider { - public static register(server: GalaxyWorkflowLanguageServer): FormattingProvider { - return new FormattingProvider(server); - } +import { ServerEventHandler } from "./handler"; +export class FormattingHandler extends ServerEventHandler { constructor(server: GalaxyWorkflowLanguageServer) { super(server); - this.server.connection.onDocumentFormatting((params) => this.onDocumentFormatting(params)); - this.server.connection.onDocumentRangeFormatting((params) => this.onDocumentRangeFormatting(params)); + this.register(this.server.connection.onDocumentFormatting((params) => this.onDocumentFormatting(params))); + this.register(this.server.connection.onDocumentRangeFormatting((params) => this.onDocumentRangeFormatting(params))); } public onDocumentFormatting(params: DocumentFormattingParams): TextEdit[] { diff --git a/server/packages/server-common/src/providers/handler.ts b/server/packages/server-common/src/providers/handler.ts new file mode 100644 index 0000000..ddf5e66 --- /dev/null +++ b/server/packages/server-common/src/providers/handler.ts @@ -0,0 +1,20 @@ +import { Disposable } from "vscode-languageserver"; +import { GalaxyWorkflowLanguageServer } from "../languageTypes"; + +/** + * Base class for all server event handlers. + * + * Used to register event handlers for the language server. + */ +export abstract class ServerEventHandler { + private disposables: Disposable[] = []; + constructor(public server: GalaxyWorkflowLanguageServer) {} + + protected register(disposable: Disposable): void { + this.disposables.push(disposable); + } + + public dispose(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + } +} diff --git a/server/packages/server-common/src/providers/hover/hoverProvider.ts b/server/packages/server-common/src/providers/hover/hoverHandler.ts similarity index 82% rename from server/packages/server-common/src/providers/hover/hoverProvider.ts rename to server/packages/server-common/src/providers/hover/hoverHandler.ts index 4e30e43..4294208 100644 --- a/server/packages/server-common/src/providers/hover/hoverProvider.ts +++ b/server/packages/server-common/src/providers/hover/hoverHandler.ts @@ -1,27 +1,20 @@ import { + GalaxyWorkflowLanguageServer, Hover, + HoverContentContributor, HoverParams, - MarkupKind, MarkupContent, - HoverContentContributor, - GalaxyWorkflowLanguageServer, + MarkupKind, } from "../../languageTypes"; -import { Provider } from "../provider"; +import { ServerEventHandler } from "../handler"; -export class HoverProvider extends Provider { +export class HoverHandler extends ServerEventHandler { private contributors: HoverContentContributor[]; - public static register( - server: GalaxyWorkflowLanguageServer, - contributors?: HoverContentContributor[] - ): HoverProvider { - return new HoverProvider(server, contributors); - } - constructor(server: GalaxyWorkflowLanguageServer, contributors?: HoverContentContributor[]) { super(server); this.contributors = contributors ?? []; - this.server.connection.onHover((params) => this.onHover(params)); + this.register(this.server.connection.onHover((params) => this.onHover(params))); } private async onHover(params: HoverParams): Promise { diff --git a/server/packages/server-common/src/providers/provider.ts b/server/packages/server-common/src/providers/provider.ts deleted file mode 100644 index 4125c3f..0000000 --- a/server/packages/server-common/src/providers/provider.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { GalaxyWorkflowLanguageServer } from "../languageTypes"; - -export abstract class Provider { - constructor(public server: GalaxyWorkflowLanguageServer) {} -} diff --git a/server/packages/server-common/src/providers/symbolsHandler.ts b/server/packages/server-common/src/providers/symbolsHandler.ts new file mode 100644 index 0000000..7151fcc --- /dev/null +++ b/server/packages/server-common/src/providers/symbolsHandler.ts @@ -0,0 +1,19 @@ +import { DocumentSymbol, DocumentSymbolParams, GalaxyWorkflowLanguageServer } from "../languageTypes"; +import { ServerEventHandler } from "./handler"; + +export class SymbolsHandler extends ServerEventHandler { + constructor(server: GalaxyWorkflowLanguageServer) { + super(server); + this.register(this.server.connection.onDocumentSymbol((params) => this.onDocumentSymbol(params))); + } + + public onDocumentSymbol(params: DocumentSymbolParams): DocumentSymbol[] { + const documentContext = this.server.documentsCache.get(params.textDocument.uri); + if (documentContext) { + const languageService = this.server.getLanguageServiceById(documentContext.languageId); + const result = languageService.getSymbols(documentContext); + return result; + } + return []; + } +} diff --git a/server/packages/server-common/src/providers/symbolsProvider.ts b/server/packages/server-common/src/providers/symbolsProvider.ts index 1a7ae15..d880043 100644 --- a/server/packages/server-common/src/providers/symbolsProvider.ts +++ b/server/packages/server-common/src/providers/symbolsProvider.ts @@ -1,35 +1,22 @@ +import { injectable } from "inversify"; import { ASTNode, ObjectASTNode, PropertyASTNode } from "../ast/types"; -import { - DocumentContext, - DocumentSymbol, - DocumentSymbolParams, - GalaxyWorkflowLanguageServer, - SymbolKind, -} from "../languageTypes"; -import { Provider } from "./provider"; +import { DocumentContext, DocumentSymbol, SymbolKind, SymbolsProvider } from "../languageTypes"; -const IGNORE_SYMBOL_NAMES = new Set(["a_galaxy_workflow", "position", "uuid", "errors", "format-version", "version"]); +@injectable() +export class SymbolsProviderBase implements SymbolsProvider { + /** + * Set of symbol names to ignore when generating the outline. + * This is useful to avoid showing internal properties in the outline. + */ + protected symbolNamesToIgnore = new Set(); -export class SymbolsProvider extends Provider { - public static register(server: GalaxyWorkflowLanguageServer): SymbolsProvider { - return new SymbolsProvider(server); - } - - constructor(server: GalaxyWorkflowLanguageServer) { - super(server); - this.server.connection.onDocumentSymbol((params) => this.onDocumentSymbol(params)); - } - - public onDocumentSymbol(params: DocumentSymbolParams): DocumentSymbol[] { - const documentContext = this.server.documentsCache.get(params.textDocument.uri); - if (documentContext) { - const symbols = this.getSymbols(documentContext); - return symbols; - } - return []; - } + /** + * Set of property names that are considered containers of steps. + * This is useful to determine if a property is a step, input, or output. + */ + protected stepContainerNames = new Set(); - private getSymbols(documentContext: DocumentContext): DocumentSymbol[] { + public getSymbols(documentContext: DocumentContext): DocumentSymbol[] { const root = documentContext.nodeManager.root; if (!root) { return []; @@ -41,46 +28,24 @@ export class SymbolsProvider extends Provider { const collectOutlineEntries = (node: ASTNode, result: DocumentSymbol[]): void => { if (node.type === "array") { node.items.forEach((node, index) => { - if (node) { - const name = this.getNodeName(node) || String(index); - if (IGNORE_SYMBOL_NAMES.has(name)) { - return; - } - const range = documentContext.nodeManager.getNodeRange(node); - const selectionRange = range; - const symbol = { name, kind: this.getSymbolKind(node.type), range, selectionRange, children: [] }; + if (!node) { + return; + } + const symbol = this.getArrayItemSymbol(node, index, documentContext); + if (symbol) { result.push(symbol); - toVisit.push({ result: symbol.children, node }); + toVisit.push({ result: symbol.children!, node }); } }); } else if (node.type === "object") { node.properties.forEach((property: PropertyASTNode) => { - const valueNode = property.valueNode; - if (valueNode) { - let name = undefined; - let customSymbol = undefined; - if (this.isStepProperty(property)) { - name = this.getNodeName(property.valueNode); - customSymbol = "step"; - } - name = name || this.getKeyLabel(property); - customSymbol = customSymbol || name; - if (IGNORE_SYMBOL_NAMES.has(name)) { - return; - } - const range = documentContext.nodeManager.getNodeRange(property); - const selectionRange = documentContext.nodeManager.getNodeRange(property.keyNode); - const children: DocumentSymbol[] = []; - const symbol: DocumentSymbol = { - name: name, - kind: this.getSymbolKind(customSymbol), - range, - selectionRange, - children, - detail: this.getDetail(valueNode), - }; + if (!property.valueNode) { + return; + } + const symbol = this.getPropertySymbol(property, documentContext); + if (symbol) { result.push(symbol); - toVisit.push({ result: children, node: valueNode }); + toVisit.push({ result: symbol.children!, node: property.valueNode }); } }); } @@ -93,18 +58,69 @@ export class SymbolsProvider extends Provider { return result; } - private isStepProperty(property: PropertyASTNode | undefined): boolean { + /** + * Determines if the given property is a step property. + * Inputs and outputs are also considered step properties. + * @param property The property to check. + * @returns True if the property is a step property, false otherwise. + */ + protected isStepProperty(property: PropertyASTNode): boolean { // The direct parent is the object containing this property and we want // to check the "steps" property which is the parent of that object const grandParent = property?.parent?.parent; if (grandParent && grandParent.type === "property") { const name = this.getKeyLabel(grandParent); - return name === "steps"; + return this.stepContainerNames.has(name); } return false; } - private getSymbolKind(nodeType: string): SymbolKind { + protected getPropertySymbol(property: PropertyASTNode, documentContext: DocumentContext): DocumentSymbol | undefined { + const name = this.getSymbolName(property); + if (this.symbolNamesToIgnore.has(name)) { + return; + } + const symbolName = this.isStepProperty(property) ? "step" : name; + const range = documentContext.nodeManager.getNodeRange(property); + const selectionRange = documentContext.nodeManager.getNodeRange(property.keyNode); + const children: DocumentSymbol[] = []; + const symbol: DocumentSymbol = { + name: name, + kind: this.getSymbolKind(symbolName), + range, + selectionRange, + children, + detail: this.getDetail(property.valueNode), + }; + return symbol; + } + + protected getArrayItemSymbol( + node: ASTNode, + index: number, + documentContext: DocumentContext + ): DocumentSymbol | undefined { + const name = this.getArrayNodeName(node, index); + if (this.symbolNamesToIgnore.has(name)) { + return; + } + const range = documentContext.nodeManager.getNodeRange(node); + const selectionRange = range; + const symbol: DocumentSymbol = { + name, + kind: this.getSymbolKind(node.type), + range, + selectionRange, + children: [], + }; + return symbol; + } + + protected getSymbolName(property: PropertyASTNode): string { + return this.getKeyLabel(property) ?? this.getNodeName(property.valueNode); + } + + protected getSymbolKind(nodeType: string): SymbolKind { switch (nodeType) { case "step": case "subworkflow": @@ -116,8 +132,9 @@ export class SymbolsProvider extends Provider { case "class": return SymbolKind.Class; case "object": - return SymbolKind.Module; + return SymbolKind.Object; case "string": + case "text": return SymbolKind.String; case "annotation": case "description": @@ -132,13 +149,18 @@ export class SymbolsProvider extends Provider { case "array": return SymbolKind.Array; case "boolean": + case "when": return SymbolKind.Boolean; - default: + case "value": return SymbolKind.Variable; + case "null": + return SymbolKind.Null; + default: + return SymbolKind.Field; } } - private getNodeName(node: ASTNode | undefined): string | undefined { + protected getNodeName(node: ASTNode | undefined): string | undefined { if (node && node.type === "object") { return this.getPropertyValueAsString(node, "name") || this.getPropertyValueAsString(node, "label"); } else if (node && node.type === "string") { @@ -147,7 +169,12 @@ export class SymbolsProvider extends Provider { return undefined; } - private getPropertyValueAsString(node: ObjectASTNode, propertyName: string): string | undefined { + protected getArrayNodeName(node: ASTNode | undefined, index: number): string { + const nodeName = this.getNodeName(node); + return nodeName || String(index + 1); + } + + protected getPropertyValueAsString(node: ObjectASTNode, propertyName: string): string | undefined { const nameProp = node.properties.find((p) => !!p.valueNode?.value && p.keyNode.value === propertyName); if (nameProp) { return nameProp.valueNode?.value?.toString(); @@ -155,7 +182,7 @@ export class SymbolsProvider extends Provider { return undefined; } - private getKeyLabel(property: PropertyASTNode): string { + protected getKeyLabel(property: PropertyASTNode): string { let name = String(property.keyNode.value); if (name) { name = name.replace(/[\n]/g, "↵"); @@ -166,7 +193,7 @@ export class SymbolsProvider extends Provider { return `"${name}"`; } - private getDetail(node: ASTNode | undefined): string | undefined { + protected getDetail(node: ASTNode | undefined): string | undefined { if (!node) { return undefined; } diff --git a/server/packages/server-common/src/server.ts b/server/packages/server-common/src/server.ts index a52aac4..c712918 100644 --- a/server/packages/server-common/src/server.ts +++ b/server/packages/server-common/src/server.ts @@ -17,14 +17,15 @@ import { WorkflowLanguageService, WorkflowTestsLanguageService, } from "./languageTypes"; -import { FormattingProvider } from "./providers/formattingProvider"; -import { HoverProvider } from "./providers/hover/hoverProvider"; -import { SymbolsProvider } from "./providers/symbolsProvider"; +import { FormattingHandler } from "./providers/formattingHandler"; +import { HoverHandler } from "./providers/hover/hoverHandler"; +import { SymbolsHandler } from "./providers/symbolsHandler"; import { CleanWorkflowService } from "./services/cleanWorkflow"; // import { DebugHoverContentContributor } from "./providers/hover/debugHoverContentContributor"; import { inject, injectable } from "inversify"; import { ConfigService } from "./configService"; -import { CompletionProvider } from "./providers/completionProvider"; +import { CompletionHandler } from "./providers/completionHandler"; +import { ServerEventHandler } from "./providers/handler"; import { ValidationProfiles } from "./providers/validation/profiles"; @injectable() @@ -32,6 +33,7 @@ export class GalaxyWorkflowLanguageServerImpl implements GalaxyWorkflowLanguageS public readonly documents = new TextDocuments(TextDocument); protected workspaceFolders: WorkspaceFolder[] | null | undefined; private languageServiceMapper: Map> = new Map(); + private serverEventHandlers: ServerEventHandler[] = []; constructor( @inject(TYPES.Connection) public readonly connection: Connection, @@ -51,7 +53,7 @@ export class GalaxyWorkflowLanguageServerImpl implements GalaxyWorkflowLanguageS this.connection.onInitialize((params) => this.initialize(params)); - this.registerProviders(); + this.registerHandlers(); this.registerServices(); @@ -89,13 +91,15 @@ export class GalaxyWorkflowLanguageServerImpl implements GalaxyWorkflowLanguageS }; } - private registerProviders(): void { - FormattingProvider.register(this); - HoverProvider.register(this, [ - // new DebugHoverContentContributor(), //TODO remove this contributor before release - ]); - SymbolsProvider.register(this); - CompletionProvider.register(this); + private registerHandlers(): void { + this.serverEventHandlers.push(new FormattingHandler(this)); + this.serverEventHandlers.push( + new HoverHandler(this, [ + // new DebugHoverContentContributor(), //TODO remove this contributor before release + ]) + ); + this.serverEventHandlers.push(new SymbolsHandler(this)); + this.serverEventHandlers.push(new CompletionHandler(this)); } private registerServices(): void { @@ -132,6 +136,7 @@ export class GalaxyWorkflowLanguageServerImpl implements GalaxyWorkflowLanguageS private cleanup(): void { this.documentsCache.dispose(); + this.serverEventHandlers.forEach((handler) => handler.dispose()); } private async validateDocument(documentContext: DocumentContext): Promise { diff --git a/server/packages/workflow-tests-language-service/src/inversify.config.ts b/server/packages/workflow-tests-language-service/src/inversify.config.ts index b7e58eb..04dbe7b 100644 --- a/server/packages/workflow-tests-language-service/src/inversify.config.ts +++ b/server/packages/workflow-tests-language-service/src/inversify.config.ts @@ -1,4 +1,8 @@ -import { TYPES as COMMON_TYPES, WorkflowTestsLanguageService } from "@gxwf/server-common/src/languageTypes"; +import { + TYPES as COMMON_TYPES, + SymbolsProvider, + WorkflowTestsLanguageService, +} from "@gxwf/server-common/src/languageTypes"; import { ContainerModule } from "inversify"; import { GxWorkflowTestsLanguageServiceImpl } from "./languageService"; import { JSONSchemaService, JSONSchemaServiceImpl } from "./schema/adapter"; @@ -6,6 +10,7 @@ import { WorkflowTestsSchemaProvider, WorkflowTestsSchemaProviderImpl } from "./ import { WorkflowTestsSchemaService, WorkflowTestsSchemaServiceImpl } from "./schema/service"; import { WorkflowTestsCompletionService, WorkflowTestsCompletionServiceImpl } from "./services/completion"; import { WorkflowTestsHoverService, WorkflowTestsHoverServiceImpl } from "./services/hover"; +import { WorkflowTestsSymbolsProvider } from "./services/symbols"; import { WorkflowTestsValidationService, WorkflowTestsValidationServiceImpl } from "./services/validation"; import { TYPES } from "./types"; @@ -33,4 +38,6 @@ export const WorkflowTestsLanguageServiceContainerModule = new ContainerModule(( bind(COMMON_TYPES.WorkflowTestsLanguageService) .to(GxWorkflowTestsLanguageServiceImpl) .inSingletonScope(); + + bind(TYPES.WorkflowTestsSymbolsProvider).to(WorkflowTestsSymbolsProvider).inSingletonScope(); }); diff --git a/server/packages/workflow-tests-language-service/src/languageService.ts b/server/packages/workflow-tests-language-service/src/languageService.ts index 98ffc4f..8885147 100644 --- a/server/packages/workflow-tests-language-service/src/languageService.ts +++ b/server/packages/workflow-tests-language-service/src/languageService.ts @@ -1,11 +1,13 @@ import { CompletionList, Diagnostic, + DocumentSymbol, FormattingOptions, Hover, LanguageServiceBase, Position, Range, + SymbolsProvider, TextDocument, TextEdit, WorkflowTestsDocument, @@ -32,7 +34,8 @@ export class GxWorkflowTestsLanguageServiceImpl extends LanguageServiceBase { return this.validationService.doValidation(documentContext); } + + public override getSymbols(documentContext: WorkflowTestsDocument): DocumentSymbol[] { + return this.symbolsProvider.getSymbols(documentContext); + } } diff --git a/server/packages/workflow-tests-language-service/src/services/symbols.ts b/server/packages/workflow-tests-language-service/src/services/symbols.ts new file mode 100644 index 0000000..50c93c8 --- /dev/null +++ b/server/packages/workflow-tests-language-service/src/services/symbols.ts @@ -0,0 +1,51 @@ +import { ASTNode } from "@gxwf/server-common/src/ast/types"; +import { SymbolKind } from "@gxwf/server-common/src/languageTypes"; +import { SymbolsProviderBase } from "@gxwf/server-common/src/providers/symbolsProvider"; +import { injectable } from "inversify"; + +@injectable() +export class WorkflowTestsSymbolsProvider extends SymbolsProviderBase { + constructor() { + super(); + this.stepContainerNames = new Set(["job", "outputs"]); + } + + protected override getArrayNodeName(node: ASTNode | undefined, index: number): string { + const isTopNode = node?.parent?.parent === undefined; + return this.getNodeName(node) ?? isTopNode ? `Test ${index + 1}` : String(index); + } + + protected override getSymbolKind(nodeType: string): SymbolKind { + if ( + nodeType.startsWith("has_") || + nodeType.startsWith("is_") || + nodeType.endsWith("_is") || + nodeType.endsWith("_matches") || + nodeType.startsWith("can_") + ) { + return SymbolKind.Boolean; + } + switch (nodeType) { + case "doc": + return SymbolKind.String; + case "job": + case "elements": + case "element_tests": + case "asserts": + return SymbolKind.Array; + case "n": + case "max": + case "min": + case "delta": + return SymbolKind.Number; + case "path": + return SymbolKind.File; + case "expression": + return SymbolKind.Function; + case "filetype": + case "collection_type": + return SymbolKind.Enum; + } + return super.getSymbolKind(nodeType); + } +} diff --git a/server/packages/workflow-tests-language-service/src/types.ts b/server/packages/workflow-tests-language-service/src/types.ts index 53efaf6..80c07af 100644 --- a/server/packages/workflow-tests-language-service/src/types.ts +++ b/server/packages/workflow-tests-language-service/src/types.ts @@ -5,4 +5,5 @@ export const TYPES = { WorkflowTestsHoverService: Symbol.for("WorkflowTestsHoverService"), WorkflowTestsCompletionService: Symbol.for("WorkflowTestsCompletionService"), WorkflowTestsValidationService: Symbol.for("WorkflowTestsValidationService"), + WorkflowTestsSymbolsProvider: Symbol.for("WorkflowTestsSymbolsProvider"), }; diff --git a/server/packages/workflow-tests-language-service/tests/unit/symbols.test.ts b/server/packages/workflow-tests-language-service/tests/unit/symbols.test.ts new file mode 100644 index 0000000..d8902f0 --- /dev/null +++ b/server/packages/workflow-tests-language-service/tests/unit/symbols.test.ts @@ -0,0 +1,53 @@ +import { DocumentSymbol } from "@gxwf/server-common/src/languageTypes"; + +import "reflect-metadata"; +import { WorkflowTestsSymbolsProvider } from "../../src/services/symbols"; +import { createGxWorkflowTestsDocument } from "../testHelpers"; + +describe("Format2 Workflow Symbols Provider", () => { + let provider: WorkflowTestsSymbolsProvider; + beforeAll(() => { + provider = new WorkflowTestsSymbolsProvider(); + }); + + function getSymbols(contents: string): DocumentSymbol[] { + const documentContext = createGxWorkflowTestsDocument(contents); + return provider.getSymbols(documentContext); + } + + it("should return top level symbols as 'Test {index+1}'", () => { + const content = ` +- doc: a test +- job: +- doc: another test + `; + const symbols = getSymbols(content); + expect(symbols.length).toBe(3); + expect(symbols[0].name).toBe("Test 1"); + expect(symbols[1].name).toBe("Test 2"); + expect(symbols[2].name).toBe("Test 3"); + }); + + it("should return symbols for each test", () => { + const content = ` +- doc: a test + job: + input_1: + class: File + path: a + input 2: b + 'input:3': c`; + const symbols = getSymbols(content); + expect(symbols.length).toBe(1); + const testSymbol = symbols[0]; + expect(testSymbol.name).toBe("Test 1"); + expect(testSymbol.children?.length).toBe(2); + expect(testSymbol.children?.at(0)?.name).toBe("doc"); + const jobSymbol = testSymbol.children?.at(1); + expect(jobSymbol?.name).toBe("job"); + expect(jobSymbol?.children?.length).toBe(3); + expect(jobSymbol?.children?.at(0)?.name).toBe("input_1"); + expect(jobSymbol?.children?.at(1)?.name).toBe("input 2"); + expect(jobSymbol?.children?.at(2)?.name).toBe("input:3"); + }); +});