project/lib/validation/ValidationError.js

import chalk from "chalk";
import escapeStringRegExp from "escape-string-regexp";

/**
 * Error class for validation of project configuration.
 *
 * @public
 * @class
 * @alias @ui5/project/validation/ValidationError
 * @extends Error
 * @hideconstructor
 */
class ValidationError extends Error {
	constructor({errors, project, yaml}) {
		super();

		/**
		 * ValidationError
		 *
		 * @constant
		 * @default
		 * @type {string}
		 * @readonly
		 * @public
		 */
		this.name = "ValidationError";

		this.project = project;
		this.yaml = yaml;

		this.errors = ValidationError.filterErrors(errors);

		/**
		 * Formatted error message
		 *
		 * @type {string}
		 * @readonly
		 * @public
		 */
		this.message = this.formatErrors();

		Error.captureStackTrace(this, this.constructor);
	}

	formatErrors() {
		let separator = "\n\n";
		if (process.stdout.isTTY) {
			// Add a horizontal separator line between errors in case a terminal is used
			separator += chalk.grey.dim("\u2500".repeat(process.stdout.columns || 80));
		}
		separator += "\n\n";
		let message;

		if (this.project) { // ui5-workspace.yaml is project independent, so in that case, no project is available
			message = chalk.red(`Invalid ui5.yaml configuration for project ${this.project.id}`) + "\n\n";
		} else {
			message = chalk.red(`Invalid workspace configuration.`) + "\n\n";
		}

		message += this.errors.map((error) => {
			return this.formatError(error);
		}).join(separator);
		return message;
	}

	formatError(error) {
		let errorMessage = ValidationError.formatMessage(error);
		if (this.yaml && this.yaml.path && this.yaml.source) {
			const yamlExtract = ValidationError.getYamlExtract({error, yaml: this.yaml});
			const errorLines = errorMessage.split("\n");
			errorLines.splice(1, 0, "\n" + yamlExtract);
			errorMessage = errorLines.join("\n");
		}
		return errorMessage;
	}

	static formatMessage(error) {
		if (error.keyword === "errorMessage") {
			return error.message;
		}

		let message = "Configuration ";
		if (error.dataPath) {
			message += chalk.underline(chalk.red(error.dataPath.substr(1))) + " ";
		}

		switch (error.keyword) {
		case "additionalProperties":
			message += `property ${error.params.additionalProperty} must not be provided here`;
			break;
		case "type":
			message += `must be of type '${error.params.type}'`;
			break;
		case "required":
			message += `must have required property '${error.params.missingProperty}'`;
			break;
		case "enum":
			message += "must be equal to one of the allowed values\n";
			message += "Allowed values: " + error.params.allowedValues.join(", ");
			break;
		default:
			message += error.message;
		}

		return message;
	}

	static _findDuplicateError(error, errorIndex, errors) {
		const foundIndex = errors.findIndex(($) => {
			if ($.dataPath !== error.dataPath) {
				return false;
			} else if ($.keyword !== error.keyword) {
				return false;
			} else if (JSON.stringify($.params) !== JSON.stringify(error.params)) {
				return false;
			} else {
				return true;
			}
		});
		return foundIndex !== errorIndex;
	}

	static filterErrors(allErrors) {
		return allErrors.filter((error, i, errors) => {
			if (error.keyword === "if" || error.keyword === "oneOf") {
				return false;
			}

			return !ValidationError._findDuplicateError(error, i, errors);
		});
	}

	static analyzeYamlError({error, yaml}) {
		if (error.dataPath === "" && error.keyword === "required") {
			// There is no line/column for a missing required property on root level
			return {line: -1, column: -1};
		}

		// Skip leading /
		const objectPath = error.dataPath.substr(1).split("/");

		if (error.keyword === "additionalProperties") {
			objectPath.push(error.params.additionalProperty);
		}

		let currentSubstring;
		let currentIndex;
		if (yaml.documentIndex) {
			const matchDocumentSeparator = /^---/gm;
			let currentDocumentIndex = 0;
			let document;
			while ((document = matchDocumentSeparator.exec(yaml.source)) !== null) {
				// If the first separator is not at the beginning of the file
				// we are already at document index 1
				// Using String#trim() to remove any whitespace characters
				if (currentDocumentIndex === 0 && yaml.source.substring(0, document.index).trim().length > 0) {
					currentDocumentIndex = 1;
				}

				if (currentDocumentIndex === yaml.documentIndex) {
					currentIndex = document.index;
					currentSubstring = yaml.source.substring(currentIndex);
					break;
				}

				currentDocumentIndex++;
			}
			// Document could not be found
			if (!currentSubstring) {
				return {line: -1, column: -1};
			}
		} else {
			// In case of index 0 or no index, use whole source
			currentIndex = 0;
			currentSubstring = yaml.source;
		}

		const matchArrayElementIndentation = /([ ]*)-/;

		for (let i = 0; i < objectPath.length; i++) {
			const property = objectPath[i];
			let newIndex;

			if (isNaN(property)) {
				// Try to find a property

				// Creating a regular expression that matches the property name a line
				// except for comments, indicated by a hash sign "#".
				const propertyRegExp = new RegExp(`^[^#]*?${escapeStringRegExp(property)}`, "m");

				const propertyMatch = propertyRegExp.exec(currentSubstring);
				if (!propertyMatch) {
					return {line: -1, column: -1};
				}
				newIndex = propertyMatch.index + propertyMatch[0].length;
			} else {
				// Try to find the right index within an array definition.
				// This currently only works for arrays defined with "-" in multiple lines.
				// Arrays using square brackets are not supported.

				const matchArrayElement = /(^|\r?\n)([ ]*-[^\r\n]*)/g;
				const arrayIndex = parseInt(property);
				let a = 0;
				let firstIndentation = -1;
				let match;
				while ((match = matchArrayElement.exec(currentSubstring)) !== null) {
					const indentationMatch = match[2].match(matchArrayElementIndentation);
					if (!indentationMatch) {
						return {line: -1, column: -1};
					}
					const currentIndentation = indentationMatch[1].length;
					if (firstIndentation === -1) {
						firstIndentation = currentIndentation;
					} else if (currentIndentation !== firstIndentation) {
						continue;
					}
					if (a === arrayIndex) {
						// match[1] might be a line-break
						newIndex = match.index + match[1].length + currentIndentation;
						break;
					}
					a++;
				}
				if (!newIndex) {
					// Could not find array element
					return {line: -1, column: -1};
				}
			}
			currentIndex += newIndex;
			currentSubstring = yaml.source.substring(currentIndex);
		}

		const linesUntilMatch = yaml.source.substring(0, currentIndex).split(/\r?\n/);
		const line = linesUntilMatch.length;
		let column = linesUntilMatch[line - 1].length + 1;
		const lastPathSegment = objectPath[objectPath.length - 1];
		if (isNaN(lastPathSegment)) {
			column -= lastPathSegment.length;
		}

		return {
			line,
			column
		};
	}

	static getSourceExtract(yamlSource, line, column) {
		let source = "";
		const lines = yamlSource.split(/\r?\n/);

		// Using line numbers instead of array indices
		const startLine = Math.max(line - 2, 1);
		const endLine = Math.min(line, lines.length);
		const padLength = String(endLine).length;

		for (let currentLine = startLine; currentLine <= endLine; currentLine++) {
			const currentLineContent = lines[currentLine - 1];
			let string = chalk.gray(
				String(currentLine).padStart(padLength, " ") + ":"
			) + " " + currentLineContent + "\n";
			if (currentLine === line) {
				string = chalk.bgRed(string);
			}
			source += string;
		}

		source += " ".repeat(column + padLength + 1) + chalk.red("^");

		return source;
	}

	static getYamlExtract({error, yaml}) {
		const {line, column} = ValidationError.analyzeYamlError({error, yaml});
		if (line !== -1 && column !== -1) {
			return chalk.grey(yaml.path + ":" + line) +
				"\n\n" + ValidationError.getSourceExtract(yaml.source, line, column);
		} else {
			return chalk.grey(yaml.path) + "\n";
		}
	}
}

export default ValidationError;