import YAML from "yaml-js" import isArray from "lodash/isArray" import lodashFind from "lodash/find" import memoize from "lodash/memoize" let cachedCompose = memoize(YAML.compose) // TODO: build a custom cache based on content var MAP_TAG = "tag:yaml.org,2002:map" var SEQ_TAG = "tag:yaml.org,2002:seq" export function getLineNumberForPath(yaml, path) { // Type check if (typeof yaml !== "string") { throw new TypeError("yaml should be a string") } if (!isArray(path)) { throw new TypeError("path should be an array of strings") } var i = 0 let ast = cachedCompose(yaml) // simply walks the tree using current path recursively to the point that // path is empty return find(ast, path) function find(current, path, last) { if(!current) { // something has gone quite wrong // return the last start_mark as a best-effort if(last && last.start_mark) return last.start_mark.line return 0 } if (path.length && current.tag === MAP_TAG) { for (i = 0; i < current.value.length; i++) { var pair = current.value[i] var key = pair[0] var value = pair[1] if (key.value === path[0]) { return find(value, path.slice(1), current) } if (key.value === path[0].replace(/\[.*/, "")) { // access the array at the index in the path (example: grab the 2 in "tags[2]") var index = parseInt(path[0].match(/\[(.*)\]/)[1]) if(value.value.length === 1 && index !== 0 && !!index) { var nextVal = lodashFind(value.value[0], { value: index.toString() }) } else { // eslint-disable-next-line no-redeclare var nextVal = value.value[index] } return find(nextVal, path.slice(1), value.value) } } } if (path.length && current.tag === SEQ_TAG) { var item = current.value[path[0]] if (item && item.tag) { return find(item, path.slice(1), current.value) } } if (current.tag === MAP_TAG && !Array.isArray(last)) { return current.start_mark.line } else { return current.start_mark.line + 1 } } } /** * Get a position object with given * @param {string} yaml * YAML or JSON string * @param {array} path * an array of stings that constructs a * JSON Path similar to JSON Pointers(RFC 6901). The difference is, each * component of path is an item of the array instead of being separated with * slash(/) in a string */ export function positionRangeForPath(yaml, path) { // Type check if (typeof yaml !== "string") { throw new TypeError("yaml should be a string") } if (!isArray(path)) { throw new TypeError("path should be an array of strings") } var invalidRange = { start: {line: -1, column: -1}, end: {line: -1, column: -1} } var i = 0 let ast = cachedCompose(yaml) // simply walks the tree using astValue path recursively to the point that // path is empty. return find(ast) function find(astValue, astKeyValue) { if (astValue.tag === MAP_TAG) { for (i = 0; i < astValue.value.length; i++) { var pair = astValue.value[i] var key = pair[0] var value = pair[1] if (key.value === path[0]) { path.shift() return find(value, key) } } } if (astValue.tag === SEQ_TAG) { var item = astValue.value[path[0]] if (item && item.tag) { path.shift() return find(item, astKeyValue) } } // if path is still not empty we were not able to find the node if (path.length) { return invalidRange } const range = { start: { line: astValue.start_mark.line, column: astValue.start_mark.column, pointer: astValue.start_mark.pointer, }, end: { line: astValue.end_mark.line, column: astValue.end_mark.column, pointer: astValue.end_mark.pointer, } } if(astKeyValue) { // eslint-disable-next-line camelcase range.key_start = { line: astKeyValue.start_mark.line, column: astKeyValue.start_mark.column, pointer: astKeyValue.start_mark.pointer, } // eslint-disable-next-line camelcase range.key_end = { line: astKeyValue.end_mark.line, column: astKeyValue.end_mark.column, pointer: astKeyValue.end_mark.pointer, } } return range } } /** * Get a JSON Path for position object in the spec * @param {string} yaml * YAML or JSON string * @param {object} position * position in the YAML or JSON string with `line` and `column` properties. * `line` and `column` number are zero indexed */ export function pathForPosition(yaml, position) { // Type check if (typeof yaml !== "string") { throw new TypeError("yaml should be a string") } if (typeof position !== "object" || typeof position.line !== "number" || typeof position.column !== "number") { throw new TypeError("position should be an object with line and column" + " properties") } try { var ast = cachedCompose(yaml) } catch (e) { console.error("Error composing AST", e) const problemMark = e.problem_mark || {} const errorTraceMessage = [ yaml.split("\n").slice(problemMark.line - 5, problemMark.line + 1).join("\n"), Array(problemMark.column).fill(" ").join("") + `^----- ${e.name}: ${e.toString().split("\n")[0]}`, yaml.split("\n").slice(problemMark.line + 1, problemMark.line + 5).join("\n") ].join("\n") console.error(errorTraceMessage) return null } var path = [] return find(ast) /** * recursive find function that finds the node matching the position * @param {object} current - AST object to serach into */ function find(current) { // algorythm: // is current a promitive? // // finish recursion without modifying the path // is current a hash? // // find a key or value that position is in their range // // if key is in range, terminate recursion with exisiting path // // if a value is in range push the corresponding key to the path // // andcontinue recursion // is current an array // // find the item that position is in it"s range and push the index // // of the item to the path and continue recursion with that item. var i = 0 if (!current || [MAP_TAG, SEQ_TAG].indexOf(current.tag) === -1) { return path } if (current.tag === MAP_TAG) { for (i = 0; i < current.value.length; i++) { var pair = current.value[i] var key = pair[0] var value = pair[1] if (isInRange(key)) { return path } else if (isInRange(value)) { path.push(key.value) return find(value) } } } if (current.tag === SEQ_TAG) { for (i = 0; i < current.value.length; i++) { var item = current.value[i] if (isInRange(item)) { path.push(i.toString()) return find(item) } } } return path /** * Determines if position is in node"s range * @param {object} node - AST node * @return {Boolean} true if position is in node"s range */ function isInRange(node) { /* jshint camelcase: false */ // if node is in a single line if (node.start_mark.line === node.end_mark.line) { return (position.line === node.start_mark.line) && (node.start_mark.column <= position.column) && (node.end_mark.column >= position.column) } // if position is in the same line as start_mark if (position.line === node.start_mark.line) { return position.column >= node.start_mark.column } // if position is in the same line as end_mark if (position.line === node.end_mark.line) { return position.column <= node.end_mark.column } // if position is between start and end lines return true, otherwise // return false. return (node.start_mark.line < position.line) && (node.end_mark.line > position.line) } } } // utility fns export let pathForPositionAsync = promisifySyncFn(pathForPosition) export let positionRangeForPathAsync = promisifySyncFn(positionRangeForPath) export let getLineNumberForPathAsync = promisifySyncFn(getLineNumberForPath) function promisifySyncFn(fn) { return function(...args) { return new Promise((resolve) => resolve(fn(...args))) } }