diff --git a/server/src/languageService.ts b/server/src/languageService.ts index 1929825..6251f08 100644 --- a/server/src/languageService.ts +++ b/server/src/languageService.ts @@ -1,4 +1,13 @@ -import { ASTNode, getLanguageService, LanguageService } from "vscode-json-languageservice"; +import { + getLanguageService, + LanguageService, + LanguageServiceParams, + DocumentLanguageSettings, + Diagnostic, + JSONSchema, + LanguageSettings, + SchemaConfiguration, +} from "vscode-json-languageservice"; import { TextDocument, Range, @@ -6,7 +15,10 @@ import { TextEdit, WorkflowDocument, WorkflowLanguageService, + Position, + Hover, } from "./languageTypes"; +import NativeWorkflowSchema from "../../workflow-languages/schemas/native.schema.json"; /** * A wrapper around the JSON Language Service to support language features @@ -14,9 +26,17 @@ import { */ export class NativeWorkflowLanguageService implements WorkflowLanguageService { private _jsonLanguageService: LanguageService; + private _documentSettings: DocumentLanguageSettings = { schemaValidation: "error" }; constructor() { - this._jsonLanguageService = getLanguageService({}); + const params: LanguageServiceParams = {}; + const settings = this.getLanguageSettings(); + this._jsonLanguageService = getLanguageService(params); + this._jsonLanguageService.configure(settings); + } + + public get schema(): JSONSchema { + return NativeWorkflowSchema; } public parseWorkflowDocument(document: TextDocument): WorkflowDocument { @@ -27,8 +47,38 @@ export class NativeWorkflowLanguageService implements WorkflowLanguageService { public format(document: TextDocument, range: Range, options: FormattingOptions): TextEdit[] { return this._jsonLanguageService.format(document, range, options); } -} -export function getRange(document: TextDocument, node: ASTNode) { - return Range.create(document.positionAt(node.offset), document.positionAt(node.offset + node.length)); + public async doValidation(workflowDocument: WorkflowDocument): Promise { + const schemaValidationResults = await this._jsonLanguageService.doValidation( + workflowDocument.textDocument, + workflowDocument.jsonDocument, + this._documentSettings, + this.schema + ); + return schemaValidationResults; + } + + public async doHover(workflowDocument: WorkflowDocument, position: Position): Promise { + const hover = await this._jsonLanguageService.doHover( + workflowDocument.textDocument, + position, + workflowDocument.jsonDocument + ); + return hover; + } + + private getLanguageSettings(): LanguageSettings { + const settings: LanguageSettings = { + schemas: [this.getWorkflowSchemaConfig()], + }; + return settings; + } + + private getWorkflowSchemaConfig(): SchemaConfiguration { + return { + uri: this.schema.id ?? "", + fileMatch: ["**.ga"], + schema: this.schema, + }; + } } diff --git a/server/src/languageTypes.ts b/server/src/languageTypes.ts index ebdaec6..9bae35f 100644 --- a/server/src/languageTypes.ts +++ b/server/src/languageTypes.ts @@ -121,6 +121,7 @@ export interface FormattingOptions extends LSPFormattingOptions { export interface WorkflowLanguageService { format(document: TextDocument, range: Range, options: FormattingOptions): TextEdit[]; parseWorkflowDocument(document: TextDocument): WorkflowDocument; + doValidation(workflowDocument: WorkflowDocument): Promise; } export abstract class ServerContext { diff --git a/server/src/models/workflowDocument.ts b/server/src/models/workflowDocument.ts index 7a97078..1b77175 100644 --- a/server/src/models/workflowDocument.ts +++ b/server/src/models/workflowDocument.ts @@ -1,5 +1,5 @@ import { JSONDocument } from "vscode-json-languageservice"; -import { TextDocument } from "../languageTypes"; +import { TextDocument, Range, Position, ASTNode } from "../languageTypes"; /** * This class contains information about workflow semantics. @@ -25,4 +25,26 @@ export class WorkflowDocument { public get jsonDocument(): JSONDocument { return this._jsonDocument; } + + public getNodeAtPosition(position: Position): ASTNode | undefined { + const offset = this.textDocument.offsetAt(position); + return this.jsonDocument.getNodeFromOffset(offset); + } + + public getNodeRange(node: ASTNode): Range { + return Range.create( + this.textDocument.positionAt(node.offset), + this.textDocument.positionAt(node.offset + node.length) + ); + } + + public getNodeRangeAtPosition(position: Position): Range { + const node = this.getNodeAtPosition(position); + return node ? this.getNodeRange(node) : this.getDefaultRangeAtPosition(position); + } + + private getDefaultRangeAtPosition(position: Position): Range { + const offset = this.textDocument.offsetAt(position); + return Range.create(this.textDocument.positionAt(offset), this.textDocument.positionAt(offset + 1)); + } } diff --git a/server/src/providers/hoverProvider.ts b/server/src/providers/hoverProvider.ts index 6b61af6..6031d31 100644 --- a/server/src/providers/hoverProvider.ts +++ b/server/src/providers/hoverProvider.ts @@ -1,7 +1,6 @@ -import { Hover, HoverParams, MarkupKind, Position, ASTNode, WorkflowDocument, PropertyASTNode } from "../languageTypes"; +import { Hover, HoverParams, MarkupKind, ASTNode, PropertyASTNode } from "../languageTypes"; import { GalaxyWorkflowLanguageServer } from "../server"; import { Provider } from "./provider"; -import { getRange } from "../languageService"; import { ArrayASTNode, BooleanASTNode, NullASTNode, NumberASTNode, StringASTNode } from "vscode-json-languageservice"; export class HoverProvider extends Provider { @@ -23,12 +22,12 @@ export class HoverProvider extends Provider { if (!workflowDocument) { return undefined; } - const node = this.getNodeAtDocumentPosition(workflowDocument, params.position); + const node = workflowDocument.getNodeAtPosition(params.position); if (!node) { return undefined; } const contentLines = this.printNode(node); - const hoverRange = getRange(workflowDocument.textDocument, node); + const hoverRange = workflowDocument.getNodeRange(node); const markdown = { kind: MarkupKind.Markdown, value: contentLines.join("\n\n"), @@ -40,13 +39,6 @@ export class HoverProvider extends Provider { return result; } - private getNodeAtDocumentPosition(workflowDocument: WorkflowDocument, position: Position): ASTNode | undefined { - const document = workflowDocument.textDocument; - const offset = document.offsetAt(position); - const node = workflowDocument.jsonDocument.getNodeFromOffset(offset); - return node; - } - private printNode(node: ASTNode): string[] { const contentLines = [`## ${node.type}`]; if (node.type === "object") { diff --git a/server/src/providers/symbolsProvider.ts b/server/src/providers/symbolsProvider.ts index 17d72f7..beaff7f 100644 --- a/server/src/providers/symbolsProvider.ts +++ b/server/src/providers/symbolsProvider.ts @@ -1,12 +1,11 @@ -import { getRange } from "../languageService"; import { - TextDocument, DocumentSymbolParams, DocumentSymbol, SymbolKind, ASTNode, PropertyASTNode, ObjectASTNode, + WorkflowDocument, } from "../languageTypes"; import { GalaxyWorkflowLanguageServer } from "../server"; import { Provider } from "./provider"; @@ -26,13 +25,14 @@ export class SymbolsProvider extends Provider { public onDocumentSymbol(params: DocumentSymbolParams): DocumentSymbol[] { const workflowDocument = this.workflowDocuments.get(params.textDocument.uri); if (workflowDocument) { - const symbols = this.getSymbols(workflowDocument.textDocument, workflowDocument.jsonDocument.root); + const symbols = this.getSymbols(workflowDocument); return symbols; } return []; } - private getSymbols(document: TextDocument, root: ASTNode | undefined): DocumentSymbol[] { + private getSymbols(workflowDocument: WorkflowDocument): DocumentSymbol[] { + const root = workflowDocument.jsonDocument.root; if (!root) { return []; } @@ -48,7 +48,7 @@ export class SymbolsProvider extends Provider { if (IGNORE_SYMBOL_NAMES.has(name)) { return; } - const range = getRange(document, node); + const range = workflowDocument.getNodeRange(node); const selectionRange = range; const symbol = { name, kind: this.getSymbolKind(node.type), range, selectionRange, children: [] }; result.push(symbol); @@ -70,8 +70,8 @@ export class SymbolsProvider extends Provider { if (IGNORE_SYMBOL_NAMES.has(name)) { return; } - const range = getRange(document, property); - const selectionRange = getRange(document, property.keyNode); + const range = workflowDocument.getNodeRange(property); + const selectionRange = workflowDocument.getNodeRange(property.keyNode); const children: DocumentSymbol[] = []; const symbol: DocumentSymbol = { name: name, diff --git a/server/src/server.ts b/server/src/server.ts index ca43043..566f9b1 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -7,7 +7,7 @@ import { WorkspaceFolder, } from "vscode-languageserver"; import { CleanWorkflowCommand } from "./commands/cleanWorkflow"; -import { WorkflowLanguageService, TextDocument } from "./languageTypes"; +import { WorkflowLanguageService, TextDocument, WorkflowDocument } from "./languageTypes"; import { WorkflowDocuments } from "./models/workflowDocuments"; import { SymbolsProvider } from "./providers/symbolsProvider"; import { FormattingProvider } from "./providers/formattingProvider"; @@ -68,21 +68,34 @@ export class GalaxyWorkflowLanguageServer { this.documents.onDidClose((event) => this.onDidClose(event.document)); } - private onDocumentOpen(document: TextDocument) { - const workflowDocument = this.languageService.parseWorkflowDocument(document); + private onDocumentOpen(textDocument: TextDocument) { + const workflowDocument = this.languageService.parseWorkflowDocument(textDocument); this.workflowDocuments.addOrReplaceWorkflowDocument(workflowDocument); + this.validate(workflowDocument); } - private onDidChangeContent(document: TextDocument) { - const workflowDocument = this.languageService.parseWorkflowDocument(document); + private onDidChangeContent(textDocument: TextDocument) { + const workflowDocument = this.languageService.parseWorkflowDocument(textDocument); this.workflowDocuments.addOrReplaceWorkflowDocument(workflowDocument); + this.validate(workflowDocument); } - private onDidClose(document: TextDocument) { - this.workflowDocuments.removeWorkflowDocument(document.uri); + private onDidClose(textDocument: TextDocument) { + this.workflowDocuments.removeWorkflowDocument(textDocument.uri); + this.clearValidation(textDocument); } private cleanup() { this.workflowDocuments.dispose(); } + + private validate(workflowDocument: WorkflowDocument) { + this.languageService.doValidation(workflowDocument).then((diagnostics) => { + this.connection.sendDiagnostics({ uri: workflowDocument.textDocument.uri, diagnostics }); + }); + } + + private clearValidation(textDocument: TextDocument) { + this.connection.sendDiagnostics({ uri: textDocument.uri, diagnostics: [] }); + } } diff --git a/server/tsconfig.json b/server/tsconfig.json index 746d2bc..e869053 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -7,6 +7,8 @@ ], "module": "commonjs", "moduleResolution": "node", + "resolveJsonModule": true, + "esModuleInterop": true, "sourceMap": true, "strict": true }, diff --git a/workflow-languages/schemas/native.schema.json b/workflow-languages/schemas/native.schema.json new file mode 100644 index 0000000..3687703 --- /dev/null +++ b/workflow-languages/schemas/native.schema.json @@ -0,0 +1,210 @@ +{ + "id": "https://galaxyproject.org/schemas/workflow/native", + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "a_galaxy_workflow": { + "type": "string", + "title": "## Galaxy Workflow indicator", + "markdownDescription": "Indicates that this JSON document is a **Galaxy Workflow**", + "default": "true", + "enum": [ + "true" + ] + }, + "annotation": { + "type": "string" + }, + "creator": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "class": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "class", + "identifier", + "name" + ] + } + ] + }, + "format-version": { + "type": "string" + }, + "license": { + "type": "string" + }, + "name": { + "type": "string" + }, + "release": { + "type": "string" + }, + "steps": { + "type": "object", + "$ref": "#/definitions/step" + }, + "tags": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "uuid": { + "type": "string" + } + }, + "required": [ + "a_galaxy_workflow", + "annotation", + "creator", + "format-version", + "license", + "name", + "release", + "steps", + "tags", + "uuid" + ], + "definitions": { + "step": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[0-9]*$": { + "type": "object", + "properties": { + "annotation": { + "type": "string" + }, + "content_id": { + "$ref": "#/definitions/optionalString" + }, + "errors": { + "type": "null" + }, + "id": { + "type": "integer" + }, + "input_connections": { + "type": "object" + }, + "inputs": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "name" + ] + } + ] + }, + "label": { + "$ref": "#/definitions/optionalString" + }, + "name": { + "type": "string" + }, + "outputs": { + "type": "array", + "items": {} + }, + "position": { + "type": "object", + "properties": { + "bottom": { + "type": "number" + }, + "height": { + "type": "number" + }, + "left": { + "type": "number" + }, + "right": { + "type": "number" + }, + "top": { + "type": "number" + }, + "width": { + "type": "integer" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "tool_id": { + "$ref": "#/definitions/optionalString" + }, + "tool_state": { + "type": "string" + }, + "tool_version": { + "$ref": "#/definitions/optionalString" + }, + "type": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "workflow_outputs": { + "type": "array", + "items": {} + } + }, + "required": [ + "annotation", + "id", + "input_connections", + "inputs", + "name", + "outputs", + "position", + "tool_id", + "type", + "uuid", + "workflow_outputs" + ] + } + } + }, + "optionalString": { + "type": [ + "string", + "null" + ] + } + } +}