Skip to content

Commit

Permalink
Merge pull request #35 from davelopez/custom_validators
Browse files Browse the repository at this point in the history
Add support for custom validators
  • Loading branch information
davelopez authored May 10, 2022
2 parents 34a05d0 + d10adcf commit cb0a9b1
Show file tree
Hide file tree
Showing 14 changed files with 972 additions and 3,635 deletions.
4,249 changes: 666 additions & 3,583 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
"test:e2e": "node ./client/out/e2e/runTests.js"
},
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/jest": "^27.5.0",
"@types/mocha": "^9.1.1",
"@types/vscode": "^1.66.0",
"@types/webpack-env": "^1.16.4",
Expand All @@ -197,14 +197,14 @@
"eslint": "^8.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^28.0.2",
"jest": "^28.0.3",
"merge-options": "^3.0.4",
"mocha": "^9.2.2",
"path-browserify": "^1.0.1",
"prettier": "2.6.2",
"process": "^0.11.10",
"rimraf": "^3.0.2",
"ts-jest": "^27.1.4",
"ts-jest": "^28.0.1",
"ts-loader": "^9.2.9",
"typescript": "^4.6.3",
"webpack": "^5.72.0",
Expand Down
2 changes: 1 addition & 1 deletion server/src/commands/cleanWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class CleanWorkflowCommand extends CustomCommand {
workflowDocument: WorkflowDocument,
cleanablePropertyNames: Set<string>
): PropertyASTNode[] {
const root = workflowDocument.jsonDocument.root;
const root = workflowDocument.rootNode;
if (!root) {
return [];
}
Expand Down
41 changes: 41 additions & 0 deletions server/src/jsonUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ASTNode } from "./languageTypes";

export function getPathSegments(path: string): string[] | null {
const segments = path.split(/[/.]/);
// Skip leading `/` or `.`
if (!segments[0]) return segments.slice(1);
return segments;
}

export function getPropertyNodeFromPath(root: ASTNode, path: string): ASTNode | null {
let segments = getPathSegments(path);
if (!segments) return null;
if (segments.length === 1 && !segments[0]) return null;
let currentNode = root;
while (segments.length) {
const segment = segments[0];
segments = segments?.slice(1);
const isLast = !segments.length;
if (currentNode.type == "object") {
const property = currentNode.properties.find((p) => p.keyNode.value == segment);
if (property && isLast) return property;
if (!property?.valueNode) return null;
if (property.valueNode.type == "object") {
currentNode = property.valueNode;
} else if (property.valueNode.type == "array") {
currentNode = property.valueNode;
} else {
return null;
}
} else if (currentNode.type == "array") {
const index = Number(segment);
const itemAtIndex = currentNode.items.at(index);
if (itemAtIndex) {
currentNode = itemAtIndex;
} else {
return null;
}
}
}
return currentNode;
}
48 changes: 28 additions & 20 deletions server/src/languageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@ import {
CompletionList,
} from "./languageTypes";
import NativeWorkflowSchema from "../../workflow-languages/schemas/native.schema.json";
import { NativeWorkflowDocument } from "./models/nativeWorkflowDocument";

/**
* A wrapper around the JSON Language Service to support language features
* for native Galaxy workflow files AKA '.ga' workflows.
*/
export class NativeWorkflowLanguageService implements WorkflowLanguageService {
export class NativeWorkflowLanguageService extends WorkflowLanguageService {
private _jsonLanguageService: LanguageService;
private _documentSettings: DocumentLanguageSettings = { schemaValidation: "error" };

constructor() {
super();
const params: LanguageServiceParams = {};
const settings = this.getLanguageSettings();
this._jsonLanguageService = getLanguageService(params);
Expand All @@ -40,43 +42,49 @@ export class NativeWorkflowLanguageService implements WorkflowLanguageService {
return NativeWorkflowSchema;
}

public parseWorkflowDocument(document: TextDocument): WorkflowDocument {
public override parseWorkflowDocument(document: TextDocument): WorkflowDocument {
const jsonDocument = this._jsonLanguageService.parseJSONDocument(document);
return new WorkflowDocument(document, jsonDocument);
return new NativeWorkflowDocument(document, jsonDocument);
}

public format(document: TextDocument, range: Range, options: FormattingOptions): TextEdit[] {
public override format(document: TextDocument, range: Range, options: FormattingOptions): TextEdit[] {
return this._jsonLanguageService.format(document, range, options);
}

public async doValidation(workflowDocument: WorkflowDocument): Promise<Diagnostic[]> {
const schemaValidationResults = await this._jsonLanguageService.doValidation(
workflowDocument.textDocument,
workflowDocument.jsonDocument,
this._documentSettings,
this.schema
);
return schemaValidationResults;
}

public async doHover(workflowDocument: WorkflowDocument, position: Position): Promise<Hover | null> {
public override async doHover(workflowDocument: WorkflowDocument, position: Position): Promise<Hover | null> {
const nativeWorkflowDocument = workflowDocument as NativeWorkflowDocument;
const hover = await this._jsonLanguageService.doHover(
workflowDocument.textDocument,
nativeWorkflowDocument.textDocument,
position,
workflowDocument.jsonDocument
nativeWorkflowDocument.jsonDocument
);
return hover;
}

public async doComplete(workflowDocument: WorkflowDocument, position: Position): Promise<CompletionList | null> {
public override async doComplete(
workflowDocument: WorkflowDocument,
position: Position
): Promise<CompletionList | null> {
const nativeWorkflowDocument = workflowDocument as NativeWorkflowDocument;
const completionResult = await this._jsonLanguageService.doComplete(
workflowDocument.textDocument,
nativeWorkflowDocument.textDocument,
position,
workflowDocument.jsonDocument
nativeWorkflowDocument.jsonDocument
);
return completionResult;
}

protected override async doValidation(workflowDocument: WorkflowDocument): Promise<Diagnostic[]> {
const nativeWorkflowDocument = workflowDocument as NativeWorkflowDocument;
const schemaValidationResults = await this._jsonLanguageService.doValidation(
nativeWorkflowDocument.textDocument,
nativeWorkflowDocument.jsonDocument,
this._documentSettings,
this.schema
);
return schemaValidationResults;
}

private getLanguageSettings(): LanguageSettings {
const settings: LanguageSettings = {
schemas: [this.getWorkflowSchemaConfig()],
Expand Down
38 changes: 32 additions & 6 deletions server/src/languageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,38 @@ export interface HoverContentContributor {
onHoverContent(workflowDocument: WorkflowDocument, position: Position): string;
}

export interface WorkflowLanguageService {
format(document: TextDocument, range: Range, options: FormattingOptions): TextEdit[];
parseWorkflowDocument(document: TextDocument): WorkflowDocument;
doValidation(workflowDocument: WorkflowDocument): Promise<Diagnostic[]>;
doHover(workflowDocument: WorkflowDocument, position: Position): Promise<Hover | null>;
doComplete(workflowDocument: WorkflowDocument, position: Position): Promise<CompletionList | null>;
/**
* Interface for contributing additional diagnostics to the validation process.
*/
export interface ValidationContributor {
/**
* Validates the given workflow document and provides diagnostics.
* @param workflowDocument The workflow document
*/
validate(workflowDocument: WorkflowDocument): Promise<Diagnostic[]>;
}

export abstract class WorkflowLanguageService {
protected _validationContributors: ValidationContributor[] = [];
public abstract format(document: TextDocument, range: Range, options: FormattingOptions): TextEdit[];
public abstract parseWorkflowDocument(document: TextDocument): WorkflowDocument;
public abstract doHover(workflowDocument: WorkflowDocument, position: Position): Promise<Hover | null>;
public abstract doComplete(workflowDocument: WorkflowDocument, position: Position): Promise<CompletionList | null>;

protected abstract doValidation(workflowDocument: WorkflowDocument): Promise<Diagnostic[]>;

public setValidationContributors(contributors: ValidationContributor[]): void {
this._validationContributors = contributors;
}

public async validate(workflowDocument: WorkflowDocument): Promise<Diagnostic[]> {
const diagnostics = await this.doValidation(workflowDocument);
this._validationContributors.forEach(async (contributor) => {
const contributedDiagnostics = await contributor.validate(workflowDocument);
diagnostics.push(...contributedDiagnostics);
});
return diagnostics;
}
}

export abstract class ServerContext {
Expand Down
75 changes: 75 additions & 0 deletions server/src/models/nativeWorkflowDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { JSONDocument } from "vscode-json-languageservice";
import { getPropertyNodeFromPath } from "../jsonUtils";
import { TextDocument, Range, Position, ASTNode, WorkflowDocument } from "../languageTypes";

/**
* This class contains information about Native workflow semantics.
*/
export class NativeWorkflowDocument extends WorkflowDocument {
private _jsonDocument: JSONDocument;

constructor(textDocument: TextDocument, jsonDocument: JSONDocument) {
super(textDocument);
this._jsonDocument = jsonDocument;
}

public get jsonDocument(): JSONDocument {
return this._jsonDocument;
}

public get rootNode(): ASTNode | undefined {
return this._jsonDocument.root;
}

public override getNodeAtPosition(position: Position): ASTNode | undefined {
const offset = this.textDocument.offsetAt(position);
return this.jsonDocument.getNodeFromOffset(offset);
}

public override getDocumentRange(): Range {
const root = this.jsonDocument.root;
if (root) {
return Range.create(this.textDocument.positionAt(root.offset), this.textDocument.positionAt(root.length));
}
return Range.create(this.textDocument.positionAt(0), this.textDocument.positionAt(1));
}

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);
}

public isLastNodeInParent(node: ASTNode): boolean {
const parent = node.parent;
if (!parent || !parent.children) {
return true; // Must be root
}
const lastNode = parent.children[parent.children.length - 1];
return node === lastNode;
}

public getPreviousSiblingNode(node: ASTNode): ASTNode | null {
const parent = node.parent;
if (!parent || !parent.children) {
return null;
}
const previousNodeIndex = parent.children.indexOf(node) - 1;
if (previousNodeIndex < 0) {
return null;
}
return parent.children[previousNodeIndex];
}

public override getNodeFromPath(path: string): ASTNode | null {
const root = this._jsonDocument.root;
if (!root) return null;
return getPropertyNodeFromPath(root, path);
}
}
29 changes: 14 additions & 15 deletions server/src/models/workflowDocument.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import { JSONDocument } from "vscode-json-languageservice";
import { TextDocument, Range, Position, ASTNode } from "../languageTypes";
import { URI } from "vscode-uri";

/**
* This class contains information about workflow semantics.
*/
export class WorkflowDocument {
private _textDocument: TextDocument;
private _jsonDocument: JSONDocument;
private _documentUri: URI;
export abstract class WorkflowDocument {
protected _textDocument: TextDocument;
protected _documentUri: URI;
public abstract readonly rootNode: ASTNode | undefined;

//TODO do not pass jsonDocument directly
constructor(textDocument: TextDocument, jsonDocument: JSONDocument) {
constructor(textDocument: TextDocument) {
this._textDocument = textDocument;
this._jsonDocument = jsonDocument;
this._documentUri = URI.parse(this._textDocument.uri);
}

Expand All @@ -25,13 +22,15 @@ export class WorkflowDocument {
return this._textDocument;
}

public get jsonDocument(): JSONDocument {
return this._jsonDocument;
}
public abstract getNodeAtPosition(position: Position): ASTNode | undefined;

public getNodeAtPosition(position: Position): ASTNode | undefined {
const offset = this.textDocument.offsetAt(position);
return this.jsonDocument.getNodeFromOffset(offset);
public abstract getDocumentRange(): Range;

public abstract getNodeFromPath(path: string): ASTNode | null;

/** Returns a small Range at the beginning of the document */
public getDefaultRange(): Range {
return Range.create(this.textDocument.positionAt(0), this.textDocument.positionAt(1));
}

public getNodeRange(node: ASTNode): Range {
Expand Down Expand Up @@ -67,7 +66,7 @@ export class WorkflowDocument {
return parent.children[previousNodeIndex];
}

private getDefaultRangeAtPosition(position: Position): Range {
protected getDefaultRangeAtPosition(position: Position): Range {
const offset = this.textDocument.offsetAt(position);
return Range.create(this.textDocument.positionAt(offset), this.textDocument.positionAt(offset + 1));
}
Expand Down
2 changes: 1 addition & 1 deletion server/src/providers/symbolsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class SymbolsProvider extends Provider {
}

private getSymbols(workflowDocument: WorkflowDocument): DocumentSymbol[] {
const root = workflowDocument.jsonDocument.root;
const root = workflowDocument.rootNode;
if (!root) {
return [];
}
Expand Down
19 changes: 19 additions & 0 deletions server/src/providers/validation/MissingPropertyValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver-types";
import { ValidationContributor, WorkflowDocument } from "../../languageTypes";

export class MissingPropertyValidationRule implements ValidationContributor {
constructor(readonly nodePath: string, readonly severity?: DiagnosticSeverity | undefined) {}

validate(workflowDocument: WorkflowDocument): Promise<Diagnostic[]> {
const result: Diagnostic[] = [];
const targetNode = workflowDocument.getNodeFromPath(this.nodePath);
if (!targetNode) {
result.push({
message: `Property '${this.nodePath}' is missing`,
range: workflowDocument.getDefaultRange(),
severity: this.severity,
});
}
return Promise.resolve(result);
}
}
2 changes: 1 addition & 1 deletion server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class GalaxyWorkflowLanguageServer {
if (WorkflowDocuments.schemesToSkip.includes(workflowDocument.uri.scheme)) {
return;
}
this.languageService.doValidation(workflowDocument).then((diagnostics) => {
this.languageService.validate(workflowDocument).then((diagnostics) => {
this.connection.sendDiagnostics({ uri: workflowDocument.textDocument.uri, diagnostics });
});
}
Expand Down
Loading

0 comments on commit cb0a9b1

Please sign in to comment.