parse/interface/parser.js

import {type, typeMatcher} from './type'

// ============================================================================
// Type definitions
// ============================================================================

/**
 * @typedef Cite.plugins.input~format
 * @type String
 */

/**
 * @typedef Cite.plugins.input~parsers
 * @type Object
 *
 * @property {Cite.plugins.input~dataParser} parse
 * @property {Cite.plugins.input~asyncDataParser} parseAsync
 * @property {Cite.plugins.input~typeParser} parseType
 */

/**
 * @callback Cite.plugins.input~dataParser
 * @param {InputData} input
 * @return parsed data
 */

/**
 * @async
 * @callback Cite.plugins.input~asyncDataParser
 * @param {InputData} input
 * @return parsed data
 */

/**
 * @typedef Cite.plugins.input~typeParser
 * @type Object
 *
 * @property {Cite.plugins.input~dataType} dataType
 * @property {Cite.plugins.input~predicate|RegExp} predicate
 * @property {Cite.plugins.input~tokenList|RegExp} tokenList
 * @property {Cite.plugins.input~propertyConstraint|Array<Cite.plugins.input~propertyConstraint>} propertyConstraint
 * @property {Cite.plugins.input~elementConstraint|Array<Cite.plugins.input~elementConstraint>} elementConstraint
 * @property {Cite.plugins.input~format} extends
 */

/**
 * @typedef Cite.plugins.input~dataType
 * @type String
 */

/**
 * @callback Cite.plugins.input~predicate
 * @param {InputData} input
 * @return {Boolean} pass
 */

/**
 * @typedef Cite.plugins.input~tokenList
 * @type Object
 * @property {RegExp} token - token pattern
 * @property {RegExp} [split=/\s+/] - token delimiter
 * @property {Boolean} [every=true] - match every token, or only some
 * @property {Boolean} [trim=true] - trim input whitespace before testing
 */

/**
 * @typedef Cite.plugins.input~propertyConstraint
 * @type Object
 * @property {String|Array<String>} [props=[]]
 * @property {String} [match='every']
 * @property {Cite.plugins.input~valuePredicate} [value]
 */

/**
 * @callback Cite.plugins.input~valuePredicate
 * @param value
 * @return {Boolean} pass
 */

/**
 * @typedef Cite.plugins.input~elementConstraint
 * @type Cite.plugins.input~format
 */

export class TypeParser {
  validDataTypes = ['String', 'Array', 'SimpleObject', 'ComplexObject', 'Primitive']

  /**
   * @class TypeParser
   * @memberof Cite.plugins.input.util
   * @param {Cite.plugins.input~typeParser}
   */
  constructor (data) {
    this.data = data
  }

  // ==========================================================================
  // Validation
  // ==========================================================================

  validateDataType () {
    const dataType = this.data.dataType
    if (dataType && !this.validDataTypes.includes(dataType)) {
      throw new RangeError(`dataType was ${dataType}; expected one of ${this.validDataTypes}`)
    }
  }

  validateParseType () {
    const predicate = this.data.predicate
    if (predicate && !(predicate instanceof RegExp || typeof predicate === 'function')) {
      throw new TypeError(`predicate was ${typeof predicate}; expected RegExp or function`)
    }
  }

  validateTokenList () {
    const tokenList = this.data.tokenList
    if (tokenList && typeof tokenList !== 'object') {
      throw new TypeError(`tokenList was ${typeof tokenList}; expected object or RegExp`)
    }
  }

  validatePropertyConstraint () {
    const propertyConstraint = this.data.propertyConstraint
    if (propertyConstraint && typeof propertyConstraint !== 'object') {
      throw new TypeError(`propertyConstraint was ${typeof propertyConstraint}; expected array or object`)
    }
  }

  validateElementConstraint () {
    const elementConstraint = this.data.elementConstraint
    if (elementConstraint && typeof elementConstraint !== 'string') {
      throw new TypeError(`elementConstraint was ${typeof elementConstraint}; expected string`)
    }
  }

  validateExtends () {
    const extend = this.data.extends
    if (extend && typeof extend !== 'string') {
      throw new TypeError(`extends was ${typeof extend}; expected string`)
    }
  }

  validate () {
    if (this.data === null || typeof this.data !== 'object') {
      throw new TypeError(`typeParser was ${typeof this.data}; expected object`)
    }
    this.validateDataType()
    this.validateParseType()
    this.validateTokenList()
    this.validatePropertyConstraint()
    this.validateElementConstraint()
    this.validateExtends()
  }

  // ==========================================================================
  // Simplification helpers
  // ==========================================================================

  parseTokenList () {
    let tokenList = this.data.tokenList

    if (!tokenList) {
      return []
    } else if (tokenList instanceof RegExp) {
      tokenList = {token: tokenList}
    }

    let {token, split = /\s+/, trim = true, every = true} = tokenList

    let trimInput = (input) => trim ? input.trim() : input
    let testTokens = every ? 'every' : 'some'

    let predicate = (input) =>
      trimInput(input).split(split)[testTokens](part => token.test(part))

    return [predicate]
  }

  parsePropertyConstraint () {
    let constraints = [].concat(this.data.propertyConstraint || [])

    return constraints.map(({props, match = 'every', value = () => true}) => {
      props = [].concat(props)

      return input => props[match](prop => prop in input && value(input[prop]))
    })
  }

  parseElementConstraint () {
    let constraint = this.data.elementConstraint
    return !constraint ? [] : [input => input.every(entry => type(entry) === constraint)]
  }

  parsePredicate () {
    if (this.data.predicate instanceof RegExp) {
      return [this.data.predicate.test.bind(this.data.predicate)]
    } else if (this.data.predicate) {
      return [this.data.predicate]
    } else {
      return []
    }
  }

  getCombinedPredicate () {
    let predicates = [
      ...this.parsePredicate(),
      ...this.parseTokenList(),
      ...this.parsePropertyConstraint(),
      ...this.parseElementConstraint()
    ]

    if (predicates.length === 0) {
      return () => true
    } else if (predicates.length === 1) {
      return predicates[0]
    } else {
      return input => predicates.every(predicate => predicate(input))
    }
  }

  getDataType () {
    if (this.data.dataType) {
      return this.data.dataType
    } else if (this.data.predicate instanceof RegExp) {
      return 'String'
    } else if (this.data.tokenList) {
      return 'String'
    } else if (this.data.elementConstraint) {
      return 'Array'
    } else {
      return 'Primitive'
    }
  }

  // ==========================================================================
  // Data simplification
  // ==========================================================================

  get dataType () {
    return this.getDataType()
  }

  get predicate () {
    return this.getCombinedPredicate()
  }

  get extends () {
    return this.data.extends
  }
}

export class DataParser {
  /**
   * @class DataParser
   * @memberof Cite.plugins.input.util
   * @param {Cite.plugins.input~dataParser|Cite.plugins.input~asyncDataParser} parser
   * @param {Object} options
   * @param {Boolean} [options.async=false]
   */
  constructor (parser, {async} = {}) {
    this.parser = parser
    this.async = async
  }

  // ==========================================================================
  // Validation
  // ==========================================================================

  validate () {
    const parser = this.parser
    if (typeof parser !== 'function') {
      throw new TypeError(`parser was ${typeof parser}; expected function`)
    }
  }
}

export class FormatParser {
  /**
   * @class FormatParser
   * @memberof Cite.plugins.input.util
   * @param {Cite.plugins.input~format} format
   * @param {Cite.plugins.input~parsers} parsers
   */
  constructor (format, parsers = {}) {
    this.format = format

    if (parsers.parseType) {
      this.typeParser = new TypeParser(parsers.parseType)
    }
    if (parsers.parse) {
      this.dataParser = new DataParser(parsers.parse, {async: false})
    }
    if (parsers.parseAsync) {
      this.asyncDataParser = new DataParser(parsers.parseAsync, {async: true})
    }
  }

  // ==========================================================================
  // Validation
  // ==========================================================================

  validateFormat () {
    const format = this.format
    if (!typeMatcher.test(format)) {
      throw new TypeError(`format name was "${format}"; didn't match expected pattern`)
    }
  }

  validate () {
    this.validateFormat()
    if (this.typeParser) {
      this.typeParser.validate()
    }
    if (this.dataParser) {
      this.dataParser.validate()
    }
    if (this.asyncDataParser) {
      this.asyncDataParser.validate()
    }
  }
}