Source: compoundPath.js

/**
 *  Accessible via `require('@buggyorg/graphtools').CompoundPath`
 *
 * A compound path is a unique representation of a node in the graph. It is defined as an array of
 * parent nodes starting at the root level, e.g. `['A', 'B', 'C']` points to the node `C` whose parent
 * is `B` and the parent of `B` is `A`. All methods accept the array notation or the shorthand string
 * notation. The shorthand string notation starts with a `»` (ALT-GR+Y) and separates each node with
 * a `»`, e.g. `»A»B»C` describes the exact same path as above. For elements on the root level it is
 * okay to omit the `»`, i.e. `»A` is the same as `A`.
 * @module CompoundPath */

import curry from 'lodash/fp/curry'
import _ from 'lodash'

/**
 * Converts a compound path into its string representation. The seperate parts are divided by a '»'.
 * @param {String[]} compoundPathArr An array of node IDs reperesenting the compound path.
 * @returns {String} The string representation of the compound path.
 */
export function toString (compoundPathArr) {
  if (compoundPathArr.length === 1) return compoundPathArr[0]
  return compoundPathArr.reduce((acc, n) => acc + '»' + n, '')
}

/**
 * Converts a compound path string into its array representation. The seperate parts must be divided by a '»'.
 * @param {String} compoundPathStr A string reperesenting the compound path divded by '»'.
 * @returns {String[]} An array of node IDs representing the compound path.
 */
export function fromString (compoundPathStr) {
  if (compoundPathStr.indexOf('»') === -1) return [compoundPathStr]
  return compoundPathStr.split('»').slice(1)
}

/**
 * Returns whether a string represents a compound path or not.
 * @param {string} path The path string to test.
 * @returns {boolean} True if the path represents a compound path, false otherwise.
 */
export function isCompoundPath (path) {
  return Array.isArray(path) || (typeof (path) === 'string' && path[0] === '»')
}

/**
 * Convert a path representation into its normalized array form.
 * @param {string|string[]} path The path as a string or array.
 * @returns {CompoundPath} The normalized path.
 */
export function normalize (path) {
  if (Array.isArray(path)) {
    return _.compact(path)
  } else {
    return _.compact(fromString(path))
  }
}

/**
 * Joins two paths into one.
 * @param {CompoundPath} base The prefix of the new path
 * @param {CompoundPath} rest The postfix of the new path.
 * @returns {CompoundPath} The new path in the form `<base>»<rest>`.
 */
export function join (base, rest) {
  if (!base) return rest
  return _.concat(normalize(base), normalize(rest))
}

/**
 * Returns whether a path points to the root element or not.
 * @param {CompoundPath} path The path to check
 * @returns {boolean} True if the path points to the root element ('', '»' or []), false otherwise.
 */
export function isRoot (path) {
  if (typeof (path) === 'string') {
    if (path === '') return true
    path = fromString(path)
  }
  return path.length === 0
}

/**
 * Returns the parent of a compound path.
 * @param {CompoundPath|string} path The path either as a string or an array.
 * @returns {CompoundPath|string} The parent of the path in the same format as the input.
 * @throws {Error} If the input format is invalid.
 */
export function parent (path) {
  if (typeof (path) === 'string') {
    return toString(parent(fromString(path)))
  } else if (Array.isArray(path)) {
    return path.slice(0, -1)
  } else {
    throw new Error('Malformed compound path. It must either be a string or an array of node IDs. Compounds paths was: ' + JSON.stringify(path))
  }
}

/**
 * Returns the root node in the path, i.e. the first node indicated by the path.
 * @params {CompoundPath} path The path
 * @returns {CompoundPath} The id of the base/root element in the path.
 */
export function base (path) {
  if (typeof (path) === 'string') {
    return toString(base(fromString(path)))
  } else if (Array.isArray(path)) {
    if (path.length === 1) {
      return []
    } else {
      return path.slice(0, 1)
    }
  } else {
    throw new Error('Malformed compound path. It must either be a string or an array of node IDs. Compounds paths was: ' + JSON.stringify(path))
  }
}

/**
 * Returns the node element, i.e. the last element in the path.
 * @param {CompoundPath} path The path
 * @returns {String} The id of the the element at the end of the path chain.
 */
export function node (path) {
  if (typeof (path) === 'string') {
    return node(fromString(path))
  } else if (Array.isArray(path)) {
    return path.slice(-1)
  } else {
    throw new Error('Malformed compound path. It must either be a string or an array of node IDs. Compounds paths was: ' + JSON.stringify(path))
  }
}

/**
 * Returns a new path that omits the root component.
 * @param {CompoundPath} path The path
 * @returns {CompoundPath} A path that omits the root component. E.g. rest([a, b, c]) -> [b, c].
 */
export function rest (path) {
  if (typeof (path) === 'string') {
    return rest(fromString(path))
  } else if (Array.isArray(path)) {
    return path.slice(1)
  } else {
    throw new Error('Malformed compound path. It must either be a string or an array of node IDs. Compounds paths was: ' + JSON.stringify(path))
  }
}

/**
 * @function
 * @name relativeTo
 * @description Creates a new path that shortens path1 assuming that path2 is a prefix to path1.
 * @param {CompoundPath} path1 The path to shorten
 * @param {CompoundPath} path2 The path that is a prefix of path1 and by what path1 is shortend.
 * @returns {CompoundPath} A new shortend path that has the prefix path2 removed.
 * @throws {Error} Of path2 is no prefix of path2.
 */
export const relativeTo = curry((path1, path2) => {
  if (path2.length > path1.length) {
    throw new Error('Cannot calculate relative path to a longer path. Tried to get express: ' + path1 + ' relative to: ' + path2)
  }
  if (path2.length === 0) {
    return path1
  } else if (path1[0] !== path2[0]) {
    throw new Error('Pathes are not subsets and thus the relative path cannot be calculated.' + JSON.stringify(path1) + ' and ' + JSON.stringify(path2))
  } else {
    return relativeTo(rest(path1), rest(path2))
  }
})

const isPrefix = (path1, path2, idx = 0) => {
  if (path2.length <= idx) return true
  if (path1[idx] === path2[idx]) {
    return isPrefix(path1, path2, idx + 1)
  } else return false
}

/**
 * @function
 * @name prefix
 * @description Prefixes path1 by path2. Does not duplicate entries in path1 if they are already part of path1.
 * Every entry in the path is a unique id and if path1 already has a subset of path2 this subset is not prefixed too.
 * @example
 * // Compound paths contain IDs which are usually not that short. Only for brevity.
 * prefix([d, e], [a, b]) => [a, b, d, e]
 * prefix([b, c], [a, b]) => [a, b, c]
 * prefix([a, b, c], [a, b]) => [a, b, c]
 * prefix([a, c], [a, b]) => Throws exception as path2 cannot be a prefix!
 * @param {CompoundPath} path1 The path that gets its prefix.
 * @param {CompoundPath} path2 The prefix path.
 * @returns {CompoundPath} A new path that prefixed path2 onto path1
 */
export const prefix = curry((path1, path2) => {
  if (path2.length === 0) return path1
  if (path2[0] !== path1[0]) {
    return [path2[0]].concat(prefix(path1, rest(path2)))
  } else if (isPrefix(path1, path2)) {
    return path2
  } else {
    throw new Error('Unable to apply inconsistent prefix: ' + JSON.stringify(path1) + ' , ' + JSON.stringify(path2))
  }
})

/**
 * @function
 * @name equal
 * @description Returns whether two compound paths are equal
 * @param {CompoundPath} path1 The first path to compare.
 * @param {CompoundPath} path2 The second path to compare.
 * @returns {boolean} True if the paths are the same, false otherwise.
 */
export const equal = curry((path1, path2) => {
  return _.isEqual(path1, path2)
})

/**
 * Returns if the pathes have the same parent node.
 * @param {CompoundPath} path1 One of the paths.
 * @param {CompoundPath} path2 The other path.
 * @returns {boolean} True if path1 and path2 have the same parents, false otherwise.
 */
export function sameParents (path1, path2) {
  return path1 && path2 && path1.length > 0 && path2.length > 0 && equal(parent(path1), parent(path2))
}