Source: queryEngine.js

/**
 * @namespace queryEngine
 * @memberof module:kdljs
 */

const { parse } = require('./parser/kql.js')
const { validateDocument } = require('./validator.js')

/**
 * @typedef Query
 * @memberof module:kdljs.queryEngine
 * @type {Object}
 * @property {Array<module:kdljs.queryEngine.Selector>} alternatives - Alternative selectors
 * @property {module:kdljs.queryEngine.Mapping} [mapping] - Mapping tuple
 */

/**
 * @typedef Selector
 * @memberof module:kdljs.queryEngine
 * @type {Array<module:kdljs.queryEngine.NodeFilter>}
 */

/**
 * @typedef NodeFilter
 * @memberof module:kdljs.queryEngine
 * @type {Object}
 * @property {Array<module:kdljs.queryEngine.Matcher>} matchers
 * @property {string} [operator]
 */

/**
 * @typedef Matcher
 * @memberof module:kdljs.queryEngine
 * @type {Object}
 * @property {module:kdljs.queryEngine.Accessor} [accessor]
 * @property {string} [operator]
 * @property {module:kdljs~Value} [value] - comparison value
 * @property {module:kdljs~string} [tag] - comparison tag
 */

/**
 * @typedef Accessor
 * @memberof module:kdljs.queryEngine
 * @type {Object}
 * @property {string} type
 * @property {string|number} [parameter]
 */

/**
 * @typedef Mapping
 * @memberof module:kdljs.queryEngine
 * @type {module:kdljs.queryEngine.Accessor|Array<module:kdljs.queryEngine.Accessor>}
 */

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {module:kdljs.queryEngine.Accessor} accessor
 * @param {module:kdljs~Node} node
 * @return {string|module:kdljs~Value|Array<module:kdljs~Value>|Object<string,module:kdljs~Value>|undefined} result
 */
function applyAccessor (accessor, node) {
  switch (accessor.type) {
    case 'name':
      return node.name

    case 'prop':
      return node.properties[accessor.parameter]

    case 'val':
      if (accessor.parameter && typeof accessor.parameter !== 'number') {
        throw TypeError(`Value accessor requires numeric parameter, ${typeof accessor.parameter} given`)
      }
      return node.values[accessor.parameter || 0]

    case 'props':
      return node.properties

    case 'values':
      return node.values
  }
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @constant {Object<string,string|null>} operandTypes
 */
const operandTypes = {
  '=': null,
  '!=': null,

  '>': 'number',
  '<': 'number',
  '>=': 'number',
  '<=': 'number',

  '^=': 'string',
  '$=': 'string',
  '*=': 'string'
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {undefined|module:kdljs~Value} value
 * @param {string} operator
 * @throw {TypeError}
 */
function checkOperand (value, operator) {
  const type = operandTypes[operator]
  if (type && typeof value !== type) { // eslint-disable-line valid-typeof
    throw new TypeError(`Matcher with '${operator}' operator requires a ${type} value, ${typeof value} given`)
  }
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {module:kdljs.queryEngine.Matcher} matcher
 * @param {module:kdljs~Node} node
 * @return boolean
 */
function applyMatcher (matcher, node) {
  if (!matcher.accessor) { return true }
  if (matcher.accessor.type === 'props' || matcher.accessor.type === 'values') {
    return false
  }

  if (matcher.tag) {
    if (matcher.operator !== '=') {
      throw new TypeError('Matcher of type annotations only allow simple comparisons')
    }

    return matcher.tag === applyAccessor(matcher.accessor, node.tags)
  }

  const value = applyAccessor(matcher.accessor, node)

  if (!matcher.operator) {
    return value != null
  } else if (!matcher.value) {
    throw new TypeError('Matcher with comparison operator requires comparison value, none given')
  } else if (typeof value !== typeof matcher.value) {
    throw new TypeError(`Matcher does not support coercion, ${typeof value} and ${typeof matcher.value} cannot be compared`)
  }

  checkOperand(matcher.value, matcher.operator)

  switch (matcher.operator) {
    case '=': return value === matcher.value
    case '!=': return value !== matcher.value

    // Number only
    case '>': return value > matcher.value
    case '<': return value < matcher.value
    case '>=': return value >= matcher.value
    case '<=': return value <= matcher.value

    // String only
    case '^=': return value.startsWith(matcher.value)
    case '$=': return value.endsWith(matcher.value)
    case '*=': return value.includes(matcher.value)

    default: return false
  }
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {module:kdljs.queryEngine.NodeFilter} nodeFilter
 * @param {module:kdljs~Node} node
 * @return boolean
 */
function applyNodeFilter (nodeFilter, node) {
  return nodeFilter.matchers.every(matcher => applyMatcher(matcher, node))
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {Array<module:kdljs~Node>} nodes
 * @param {boolean} includeSelf
 * @return {Array<module:kdljs~Node>}
 */
function collectChildren (nodes, includeSelf) {
  const children = nodes.flatMap(node => collectChildren(node.children, true))
  return includeSelf ? nodes.concat(children) : children
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {Array<module:kdljs~Node>} nodes
 * @return {Array<module:kdljs~Node>}
 */
function collectImmediateChildren (nodes) {
  return nodes.flatMap(node => node.children)
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {Array} array
 * @return {Array}
 */
function unique (array) {
  return array.filter((value, index) => array.indexOf(value) === index)
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {module:kdljs.queryEngine.Selector} selector
 * @param {module:kdljs~Document} doc
 * @return {Array<module:kdljs~Node>}
 */
function applySelector (selector, doc) {
  if (selector[0] && selector[0].matchers[0].accessor && selector[0].matchers[0].accessor.type === 'top') {
    selector = selector.slice(1)
  }

  let nodes = [{ children: doc }]

  for (const nodeFilter of selector) {
    switch (nodeFilter.operator) {
      case '+':
      case '~':
        throw new TypeError('Sibling selectors not supported yet.')

      case '>':
        nodes = collectImmediateChildren(nodes)
        break

      case undefined:
      default:
        nodes = collectChildren(nodes)
        break
    }

    nodes = unique(nodes).filter(node => applyNodeFilter(nodeFilter, node))
  }

  return nodes
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {module:kdljs.queryEngine.Mapping} mapping
 * @param {Array<module:kdljs~Node>} nodes
 * @return {any}
 */
function applyMapping (mapping, nodes) {
  return nodes.map(node => {
    if (Array.isArray(mapping)) {
      return mapping.map(accessor => applyAccessor(accessor, node))
    } else {
      return applyAccessor(mapping, node)
    }
  })
}

/**
 * @access private
 * @memberof module:kdljs.queryEngine
 * @param {module:kdljs.queryEngine.Query} query
 * @param {module:kdljs~Document} doc
 * @return {any}
 */
function applyQuery (query, doc) {
  const nodes = unique(query.alternatives.flatMap(selector => applySelector(selector, doc)))

  if (query.mapping) {
    return applyMapping(query.mapping, nodes)
  } else {
    return nodes
  }
}

/**
 * @memberof module:kdljs.queryEngine
 * @param {module:kdljs~Document} doc - Input KDL document
 * @param {module:kdljs~QueryString} queryString - Query for selecting and/or transforming results
 * @return {any}
 */
function query (doc, queryString) {
  if (!validateDocument(doc)) {
    throw new TypeError('Invalid KDL document')
  }

  const { output, errors } = parse(queryString)
  if (errors && errors.length) {
    throw errors[0]
  }

  return applyQuery(output, doc)
}

module.exports.query = query