project/lib/validation/ValidationError.js

  1. const chalk = require("chalk");
  2. const escapeStringRegExp = require("escape-string-regexp");
  3. /**
  4. * Error class for validation of project configuration.
  5. *
  6. * @public
  7. * @hideconstructor
  8. * @augments Error
  9. * @memberof module:@ui5/project.validation
  10. */
  11. class ValidationError extends Error {
  12. constructor({errors, project, yaml}) {
  13. super();
  14. /**
  15. * ValidationError
  16. *
  17. * @constant
  18. * @default
  19. * @type {string}
  20. * @readonly
  21. * @public
  22. */
  23. this.name = "ValidationError";
  24. this.project = project;
  25. this.yaml = yaml;
  26. this.errors = ValidationError.filterErrors(errors);
  27. /**
  28. * Formatted error message
  29. *
  30. * @type {string}
  31. * @readonly
  32. * @public
  33. */
  34. this.message = this.formatErrors();
  35. Error.captureStackTrace(this, this.constructor);
  36. }
  37. formatErrors() {
  38. let separator = "\n\n";
  39. if (process.stdout.isTTY) {
  40. // Add a horizontal separator line between errors in case a terminal is used
  41. separator += chalk.grey.dim("\u2500".repeat(process.stdout.columns || 80));
  42. }
  43. separator += "\n\n";
  44. let message = chalk.red(`Invalid ui5.yaml configuration for project ${this.project.id}`) + "\n\n";
  45. message += this.errors.map((error) => {
  46. return this.formatError(error);
  47. }).join(separator);
  48. return message;
  49. }
  50. formatError(error) {
  51. let errorMessage = ValidationError.formatMessage(error);
  52. if (this.yaml && this.yaml.path && this.yaml.source) {
  53. const yamlExtract = ValidationError.getYamlExtract({error, yaml: this.yaml});
  54. const errorLines = errorMessage.split("\n");
  55. errorLines.splice(1, 0, "\n" + yamlExtract);
  56. errorMessage = errorLines.join("\n");
  57. }
  58. return errorMessage;
  59. }
  60. static formatMessage(error) {
  61. if (error.keyword === "errorMessage") {
  62. return error.message;
  63. }
  64. let message = "Configuration ";
  65. if (error.dataPath) {
  66. message += chalk.underline(chalk.red(error.dataPath.substr(1))) + " ";
  67. }
  68. switch (error.keyword) {
  69. case "additionalProperties":
  70. message += `property ${error.params.additionalProperty} must not be provided here`;
  71. break;
  72. case "type":
  73. message += `must be of type '${error.params.type}'`;
  74. break;
  75. case "required":
  76. message += `must have required property '${error.params.missingProperty}'`;
  77. break;
  78. case "enum":
  79. message += "must be equal to one of the allowed values\n";
  80. message += "Allowed values: " + error.params.allowedValues.join(", ");
  81. break;
  82. default:
  83. message += error.message;
  84. }
  85. return message;
  86. }
  87. static _findDuplicateError(error, errorIndex, errors) {
  88. const foundIndex = errors.findIndex(($) => {
  89. if ($.dataPath !== error.dataPath) {
  90. return false;
  91. } else if ($.keyword !== error.keyword) {
  92. return false;
  93. } else if (JSON.stringify($.params) !== JSON.stringify(error.params)) {
  94. return false;
  95. } else {
  96. return true;
  97. }
  98. });
  99. return foundIndex !== errorIndex;
  100. }
  101. static filterErrors(allErrors) {
  102. return allErrors.filter((error, i, errors) => {
  103. if (error.keyword === "if" || error.keyword === "oneOf") {
  104. return false;
  105. }
  106. return !ValidationError._findDuplicateError(error, i, errors);
  107. });
  108. }
  109. static analyzeYamlError({error, yaml}) {
  110. if (error.dataPath === "" && error.keyword === "required") {
  111. // There is no line/column for a missing required property on root level
  112. return {line: -1, column: -1};
  113. }
  114. // Skip leading /
  115. const objectPath = error.dataPath.substr(1).split("/");
  116. if (error.keyword === "additionalProperties") {
  117. objectPath.push(error.params.additionalProperty);
  118. }
  119. let currentSubstring;
  120. let currentIndex;
  121. if (yaml.documentIndex) {
  122. const matchDocumentSeparator = /^---/gm;
  123. let currentDocumentIndex = 0;
  124. let document;
  125. while ((document = matchDocumentSeparator.exec(yaml.source)) !== null) {
  126. // If the first separator is not at the beginning of the file
  127. // we are already at document index 1
  128. // Using String#trim() to remove any whitespace characters
  129. if (currentDocumentIndex === 0 && yaml.source.substring(0, document.index).trim().length > 0) {
  130. currentDocumentIndex = 1;
  131. }
  132. if (currentDocumentIndex === yaml.documentIndex) {
  133. currentIndex = document.index;
  134. currentSubstring = yaml.source.substring(currentIndex);
  135. break;
  136. }
  137. currentDocumentIndex++;
  138. }
  139. // Document could not be found
  140. if (!currentSubstring) {
  141. return {line: -1, column: -1};
  142. }
  143. } else {
  144. // In case of index 0 or no index, use whole source
  145. currentIndex = 0;
  146. currentSubstring = yaml.source;
  147. }
  148. const matchArrayElementIndentation = /([ ]*)-/;
  149. for (let i = 0; i < objectPath.length; i++) {
  150. const property = objectPath[i];
  151. let newIndex;
  152. if (isNaN(property)) {
  153. // Try to find a property
  154. // Creating a regular expression that matches the property name a line
  155. // except for comments, indicated by a hash sign "#".
  156. const propertyRegExp = new RegExp(`^[^#]*?${escapeStringRegExp(property)}`, "m");
  157. const propertyMatch = propertyRegExp.exec(currentSubstring);
  158. if (!propertyMatch) {
  159. return {line: -1, column: -1};
  160. }
  161. newIndex = propertyMatch.index + propertyMatch[0].length;
  162. } else {
  163. // Try to find the right index within an array definition.
  164. // This currently only works for arrays defined with "-" in multiple lines.
  165. // Arrays using square brackets are not supported.
  166. const matchArrayElement = /(^|\r?\n)([ ]*-[^\r\n]*)/g;
  167. const arrayIndex = parseInt(property);
  168. let a = 0;
  169. let firstIndentation = -1;
  170. let match;
  171. while ((match = matchArrayElement.exec(currentSubstring)) !== null) {
  172. const indentationMatch = match[2].match(matchArrayElementIndentation);
  173. if (!indentationMatch) {
  174. return {line: -1, column: -1};
  175. }
  176. const currentIndentation = indentationMatch[1].length;
  177. if (firstIndentation === -1) {
  178. firstIndentation = currentIndentation;
  179. } else if (currentIndentation !== firstIndentation) {
  180. continue;
  181. }
  182. if (a === arrayIndex) {
  183. // match[1] might be a line-break
  184. newIndex = match.index + match[1].length + currentIndentation;
  185. break;
  186. }
  187. a++;
  188. }
  189. if (!newIndex) {
  190. // Could not find array element
  191. return {line: -1, column: -1};
  192. }
  193. }
  194. currentIndex += newIndex;
  195. currentSubstring = yaml.source.substring(currentIndex);
  196. }
  197. const linesUntilMatch = yaml.source.substring(0, currentIndex).split(/\r?\n/);
  198. const line = linesUntilMatch.length;
  199. let column = linesUntilMatch[line - 1].length + 1;
  200. const lastPathSegment = objectPath[objectPath.length - 1];
  201. if (isNaN(lastPathSegment)) {
  202. column -= lastPathSegment.length;
  203. }
  204. return {
  205. line,
  206. column
  207. };
  208. }
  209. static getSourceExtract(yamlSource, line, column) {
  210. let source = "";
  211. const lines = yamlSource.split(/\r?\n/);
  212. // Using line numbers instead of array indices
  213. const startLine = Math.max(line - 2, 1);
  214. const endLine = Math.min(line, lines.length);
  215. const padLength = String(endLine).length;
  216. for (let currentLine = startLine; currentLine <= endLine; currentLine++) {
  217. const currentLineContent = lines[currentLine - 1];
  218. let string = chalk.gray(
  219. String(currentLine).padStart(padLength, " ") + ":"
  220. ) + " " + currentLineContent + "\n";
  221. if (currentLine === line) {
  222. string = chalk.bgRed(string);
  223. }
  224. source += string;
  225. }
  226. source += " ".repeat(column + padLength + 1) + chalk.red("^");
  227. return source;
  228. }
  229. static getYamlExtract({error, yaml}) {
  230. const {line, column} = ValidationError.analyzeYamlError({error, yaml});
  231. if (line !== -1 && column !== -1) {
  232. return chalk.grey(yaml.path + ":" + line) +
  233. "\n\n" + ValidationError.getSourceExtract(yaml.source, line, column);
  234. } else {
  235. return chalk.grey(yaml.path) + "\n";
  236. }
  237. }
  238. }
  239. module.exports = ValidationError;