Source: schema/parser.js

import Set from 'core-js-pure/actual/set'
import flattenDeep from 'lodash/flattenDeep'
import zip from 'lodash/zip'
import semver from 'semver'

// TODO: Switch require once upstream bugs are fixed.
// import xpath from 'xml2js-xpath'
// Temporary
import * as xpath from '../utils/xpath'

import {
  nodeProperty,
  SchemaAttribute,
  schemaAttributeProperty,
  SchemaEntries,
  SchemaEntryManager,
  SchemaProperty,
  SchemaTag,
  SchemaUnit,
  SchemaUnitClass,
  SchemaUnitModifier,
  SchemaValueClass,
  SchemaValueTag,
} from './entries'
import { IssueError } from '../issues/issues'

import classRegex from '../data/json/classRegex.json'

const lc = (str) => str.toLowerCase()

export default class SchemaParser {
  /**
   * The root XML element.
   * @type {Object}
   */
  rootElement

  /**
   * @type {Map<string, SchemaProperty>}
   */
  properties

  /**
   * @type {Map<string, SchemaAttribute>}
   */
  attributes

  /**
   * The schema's value classes.
   * @type {SchemaEntryManager<SchemaValueClass>}
   */
  valueClasses

  /**
   * The schema's unit classes.
   * @type {SchemaEntryManager<SchemaUnitClass>}
   */
  unitClasses

  /**
   * The schema's unit modifiers.
   * @type {SchemaEntryManager<SchemaUnitModifier>}
   */
  unitModifiers

  /**
   * The schema's tags.
   * @type {SchemaEntryManager<SchemaTag>}
   */
  tags

  /**
   * Constructor.
   *
   * @param {Object} rootElement The root XML element.
   */
  constructor(rootElement) {
    this.rootElement = rootElement
    this._versionDefinitions = {
      typeProperties: new Set(['boolProperty']),
      categoryProperties: new Set([
        'elementProperty',
        'nodeProperty',
        'schemaAttributeProperty',
        'unitProperty',
        'unitClassProperty',
        'unitModifierProperty',
        'valueClassProperty',
      ]),
      roleProperties: new Set(['recursiveProperty', 'isInheritedProperty', 'annotationProperty']),
    }
  }

  parse() {
    this.populateDictionaries()
    return new SchemaEntries(this)
  }

  populateDictionaries() {
    this.parseProperties()
    this.parseAttributes()
    this.parseUnitModifiers()
    this.parseUnitClasses()
    this.parseValueClasses()
    this.parseTags()
  }

  getAllChildTags(parentElement, elementName = 'node', excludeTakeValueTags = true) {
    if (excludeTakeValueTags && this.getElementTagName(parentElement) === '#') {
      return []
    }
    const tagElementChildren = this.getElementsByName(elementName, parentElement)
    const childTags = flattenDeep(
      tagElementChildren.map((child) => this.getAllChildTags(child, elementName, excludeTakeValueTags)),
    )
    childTags.push(parentElement)
    return childTags
  }

  getElementsByName(elementName = 'node', parentElement = this.rootElement) {
    return xpath.find(parentElement, '//' + elementName)
  }

  getParentTagName(tagElement) {
    const parentTagElement = tagElement.$parent
    if (parentTagElement && parentTagElement.$parent) {
      return this.getElementTagName(parentTagElement)
    } else {
      return ''
    }
  }

  /**
   * Extract the name of an XML element.
   *
   * NOTE: This method cannot be merged into {@link getElementTagValue} because it is used as a first-class object.
   *
   * @param {Object} element An XML element.
   * @returns {string} The name of the element.
   */
  getElementTagName(element) {
    return element.name[0]._
  }

  /**
   * Extract a value from an XML element.
   *
   * @param {Object} element An XML element.
   * @param {string} tagName The tag value to extract.
   * @returns {string} The value of the tag in the element.
   */
  getElementTagValue(element, tagName) {
    return element[tagName][0]._
  }

  /**
   * Retrieve all the tags in the schema.
   *
   * @param {string} tagElementName The name of the tag element.
   * @returns {Map<Object, string>} The tag names and XML elements.
   */
  getAllTags(tagElementName = 'node') {
    const tagElements = xpath.find(this.rootElement, '//' + tagElementName)
    const tags = tagElements.map((element) => this.getElementTagName(element))
    return new Map(zip(tagElements, tags))
  }

  // Rewrite starts here.

  parseProperties() {
    const propertyDefinitions = this.getElementsByName('propertyDefinition')
    this.properties = new Map()
    for (const definition of propertyDefinitions) {
      const propertyName = this.getElementTagName(definition)
      if (this._versionDefinitions.categoryProperties?.has(propertyName)) {
        this.properties.set(
          propertyName,
          // TODO: Switch back to class constant once upstream bug is fixed.
          new SchemaProperty(propertyName, 'categoryProperty'),
        )
      } else if (this._versionDefinitions.typeProperties?.has(propertyName)) {
        this.properties.set(
          propertyName,
          // TODO: Switch back to class constant once upstream bug is fixed.
          new SchemaProperty(propertyName, 'typeProperty'),
        )
      } else if (this._versionDefinitions.roleProperties?.has(propertyName)) {
        this.properties.set(
          propertyName,
          // TODO: Switch back to class constant once upstream bug is fixed.
          new SchemaProperty(propertyName, 'roleProperty'),
        )
      }
    }
    this._addCustomProperties()
  }

  parseAttributes() {
    const attributeDefinitions = this.getElementsByName('schemaAttributeDefinition')
    this.attributes = new Map()
    for (const definition of attributeDefinitions) {
      const attributeName = this.getElementTagName(definition)
      const propertyElements = definition.property
      let properties
      if (propertyElements === undefined) {
        properties = []
      } else {
        properties = propertyElements.map((element) => this.properties.get(this.getElementTagName(element)))
      }
      this.attributes.set(attributeName, new SchemaAttribute(attributeName, properties))
    }
    this._addCustomAttributes()
  }

  _getValueClassChars(name) {
    let classChars
    if (Array.isArray(classRegex.class_chars[name]) && classRegex.class_chars[name].length > 0) {
      classChars =
        '^(?:' + classRegex.class_chars[name].map((charClass) => classRegex.char_regex[charClass]).join('|') + ')+$'
    } else {
      classChars = '^.+$' // Any non-empty line or string.
    }
    return new RegExp(classChars)
  }

  parseValueClasses() {
    const valueClasses = new Map()
    const [booleanAttributeDefinitions, valueAttributeDefinitions] = this._parseDefinitions('valueClass')
    for (const [name, valueAttributes] of valueAttributeDefinitions) {
      const booleanAttributes = booleanAttributeDefinitions.get(name)
      //valueClasses.set(name, new SchemaValueClass(name, booleanAttributes, valueAttributes))
      const charRegex = this._getValueClassChars(name)
      const wordRegex = new RegExp(classRegex.class_words[name] ?? '^.+$')
      valueClasses.set(name, new SchemaValueClass(name, booleanAttributes, valueAttributes, charRegex, wordRegex))
    }
    this.valueClasses = new SchemaEntryManager(valueClasses)
  }

  parseUnitModifiers() {
    const unitModifiers = new Map()
    const [booleanAttributeDefinitions, valueAttributeDefinitions] = this._parseDefinitions('unitModifier')
    for (const [name, valueAttributes] of valueAttributeDefinitions) {
      const booleanAttributes = booleanAttributeDefinitions.get(name)
      unitModifiers.set(name, new SchemaUnitModifier(name, booleanAttributes, valueAttributes))
    }
    this.unitModifiers = new SchemaEntryManager(unitModifiers)
  }

  parseUnitClasses() {
    const unitClasses = new Map()
    const [booleanAttributeDefinitions, valueAttributeDefinitions] = this._parseDefinitions('unitClass')
    const unitClassUnits = this.parseUnits()

    for (const [name, valueAttributes] of valueAttributeDefinitions) {
      const booleanAttributes = booleanAttributeDefinitions.get(name)
      unitClasses.set(name, new SchemaUnitClass(name, booleanAttributes, valueAttributes, unitClassUnits.get(name)))
    }
    this.unitClasses = new SchemaEntryManager(unitClasses)
  }

  parseUnits() {
    const unitClassUnits = new Map()
    const unitClassElements = this.getElementsByName('unitClassDefinition')
    const unitModifiers = this.unitModifiers
    for (const element of unitClassElements) {
      const elementName = this.getElementTagName(element)
      const units = new Map()
      unitClassUnits.set(elementName, units)
      if (element.unit === undefined) {
        continue
      }
      const [unitBooleanAttributeDefinitions, unitValueAttributeDefinitions] = this._parseAttributeElements(
        element.unit,
        this.getElementTagName,
      )
      for (const [name, valueAttributes] of unitValueAttributeDefinitions) {
        const booleanAttributes = unitBooleanAttributeDefinitions.get(name)
        units.set(name, new SchemaUnit(name, booleanAttributes, valueAttributes, unitModifiers))
      }
    }
    return unitClassUnits
  }

  // Tag parsing

  /**
   * Parse the schema's tags.
   */
  parseTags() {
    const tags = this.getAllTags()
    const shortTags = this._getShortTags(tags)
    const [booleanAttributeDefinitions, valueAttributeDefinitions] = this._parseAttributeElements(
      tags.keys(),
      (element) => shortTags.get(element),
    )

    const tagUnitClassDefinitions = this._processTagUnitClasses(shortTags, valueAttributeDefinitions)
    this._processRecursiveAttributes(shortTags, booleanAttributeDefinitions)

    const tagEntries = this._createSchemaTags(
      booleanAttributeDefinitions,
      valueAttributeDefinitions,
      tagUnitClassDefinitions,
    )

    this._injectTagFields(tags, shortTags, tagEntries)

    this.tags = new SchemaEntryManager(tagEntries)
  }

  /**
   * Generate the map from tag elements to shortened tag names.
   *
   * @param {Map<Object, string>} tags The map from tag elements to tag strings.
   * @returns {Map<Object, string>} The map from tag elements to shortened tag names.
   * @private
   */
  _getShortTags(tags) {
    const shortTags = new Map()
    for (const tagElement of tags.keys()) {
      const shortKey =
        this.getElementTagName(tagElement) === '#'
          ? this.getParentTagName(tagElement) + '-#'
          : this.getElementTagName(tagElement)
      shortTags.set(tagElement, shortKey)
    }
    return shortTags
  }

  /**
   * Process unit classes in tags.
   *
   * @param {Map<Object, string>} shortTags The map from tag elements to shortened tag names.
   * @param {Map<string, Map<SchemaAttribute, *>>} valueAttributeDefinitions The map from shortened tag names to their value schema attributes.
   * @returns {Map<string, SchemaUnitClass[]>} The map from shortened tag names to their unit classes.
   * @private
   */
  _processTagUnitClasses(shortTags, valueAttributeDefinitions) {
    const tagUnitClassAttribute = this.attributes.get('unitClass')
    const tagUnitClassDefinitions = new Map()

    for (const tagName of shortTags.values()) {
      const valueAttributes = valueAttributeDefinitions.get(tagName)
      if (valueAttributes.has(tagUnitClassAttribute)) {
        tagUnitClassDefinitions.set(
          tagName,
          valueAttributes.get(tagUnitClassAttribute).map((unitClassName) => {
            return this.unitClasses.getEntry(unitClassName)
          }),
        )
        valueAttributes.delete(tagUnitClassAttribute)
      }
    }

    return tagUnitClassDefinitions
  }

  /**
   * Process recursive schema attributes.
   *
   * @param {Map<Object, string>} shortTags The map from tag elements to shortened tag names.
   * @param {Map<string, Set<SchemaAttribute>>} booleanAttributeDefinitions The map from shortened tag names to their boolean schema attributes. Passed by reference.
   * @private
   */
  _processRecursiveAttributes(shortTags, booleanAttributeDefinitions) {
    const recursiveAttributeMap = this._generateRecursiveAttributeMap(shortTags, booleanAttributeDefinitions)

    for (const [tagElement, recursiveAttributes] of recursiveAttributeMap) {
      for (const childTag of this.getAllChildTags(tagElement)) {
        const childTagName = this.getElementTagName(childTag)
        const newBooleanAttributes = booleanAttributeDefinitions.get(childTagName)?.union(recursiveAttributes)
        booleanAttributeDefinitions.set(childTagName, newBooleanAttributes)
      }
    }
  }

  /**
   * Generate a map from tags to their recursive attributes.
   *
   * @param {Map<Object, string>} shortTags The map from tag elements to shortened tag names.
   * @param {Map<string, Set<SchemaAttribute>>} booleanAttributeDefinitions The map from shortened tag names to their boolean schema attributes. Passed by reference.
   * @private
   */
  _generateRecursiveAttributeMap(shortTags, booleanAttributeDefinitions) {
    const recursiveAttributes = this._getRecursiveAttributes()
    const recursiveAttributeMap = new Map()

    for (const [tagElement, tagName] of shortTags) {
      recursiveAttributeMap.set(tagElement, booleanAttributeDefinitions.get(tagName)?.intersection(recursiveAttributes))
    }

    return recursiveAttributeMap
  }

  _getRecursiveAttributes() {
    const attributeArray = Array.from(this.attributes.values())
    let filteredAttributeArray

    if (semver.lt(this.rootElement.$.version, '8.3.0')) {
      filteredAttributeArray = attributeArray.filter((attribute) =>
        attribute.roleProperties.has(this.properties.get('isInheritedProperty')),
      )
    } else {
      filteredAttributeArray = attributeArray.filter(
        (attribute) => !attribute.roleProperties.has(this.properties.get('annotationProperty')),
      )
    }

    return new Set(filteredAttributeArray)
  }

  /**
   * Create the {@link SchemaTag} objects.
   *
   * @param {Map<string, Set<SchemaAttribute>>} booleanAttributeDefinitions The map from shortened tag names to their boolean schema attributes.
   * @param {Map<string, Map<SchemaAttribute, *>>} valueAttributeDefinitions The map from shortened tag names to their value schema attributes.
   * @param {Map<string, SchemaUnitClass[]>} tagUnitClassDefinitions The map from shortened tag names to their unit classes.
   * @returns {Map<string, SchemaTag>} The map from lowercase shortened tag names to their tag objects.
   * @private
   */
  _createSchemaTags(booleanAttributeDefinitions, valueAttributeDefinitions, tagUnitClassDefinitions) {
    const tagTakesValueAttribute = this.attributes.get('takesValue')
    const tagEntries = new Map()

    for (const [name, valueAttributes] of valueAttributeDefinitions) {
      if (tagEntries.has(name)) {
        IssueError.generateAndThrow('duplicateTagsInSchema')
      }

      const booleanAttributes = booleanAttributeDefinitions.get(name)
      const unitClasses = tagUnitClassDefinitions.get(name)

      if (booleanAttributes.has(tagTakesValueAttribute)) {
        tagEntries.set(lc(name), new SchemaValueTag(name, booleanAttributes, valueAttributes, unitClasses))
      } else {
        tagEntries.set(lc(name), new SchemaTag(name, booleanAttributes, valueAttributes, unitClasses))
      }
    }

    return tagEntries
  }

  /**
   * Inject special tag fields into the {@link SchemaTag} objects.
   *
   * @param {Map<Object, string>} tags The map from tag elements to tag strings.
   * @param {Map<Object, string>} shortTags The map from tag elements to shortened tag names.
   * @param {Map<string, SchemaTag>} tagEntries The map from shortened tag names to tag objects.
   * @private
   */
  _injectTagFields(tags, shortTags, tagEntries) {
    for (const tagElement of tags.keys()) {
      const tagName = shortTags.get(tagElement)
      const parentTagName = shortTags.get(tagElement.$parent)

      if (parentTagName) {
        tagEntries.get(lc(tagName)).parent = tagEntries.get(lc(parentTagName))
      }

      if (this.getElementTagName(tagElement) === '#') {
        tagEntries.get(lc(parentTagName)).valueTag = tagEntries.get(lc(tagName))
      }
    }
  }

  _parseDefinitions(category) {
    const categoryTagName = category + 'Definition'
    const definitionElements = this.getElementsByName(categoryTagName)

    return this._parseAttributeElements(definitionElements, this.getElementTagName)
  }

  _parseAttributeElements(elements, namer) {
    const booleanAttributeDefinitions = new Map()
    const valueAttributeDefinitions = new Map()

    for (const element of elements) {
      const [booleanAttributes, valueAttributes] = this._parseAttributeElement(element)

      const elementName = namer(element)
      booleanAttributeDefinitions.set(elementName, booleanAttributes)
      valueAttributeDefinitions.set(elementName, valueAttributes)
    }

    return [booleanAttributeDefinitions, valueAttributeDefinitions]
  }

  _parseAttributeElement(element) {
    const booleanAttributes = new Set()
    const valueAttributes = new Map()

    const tagAttributes = element.attribute ?? []

    for (const tagAttribute of tagAttributes) {
      const attributeName = this.getElementTagName(tagAttribute)
      if (tagAttribute.value === undefined) {
        booleanAttributes.add(this.attributes.get(attributeName))
        continue
      }
      const values = tagAttribute.value.map((value) => value._)
      valueAttributes.set(this.attributes.get(attributeName), values)
    }

    return [booleanAttributes, valueAttributes]
  }

  _addCustomAttributes() {
    const isInheritedProperty = this.properties.get('isInheritedProperty')
    const extensionAllowedAttribute = this.attributes.get('extensionAllowed')
    if (this.rootElement.$.library === undefined && semver.lt(this.rootElement.$.version, '8.2.0')) {
      extensionAllowedAttribute._roleProperties.add(isInheritedProperty)
    }
    const inLibraryAttribute = this.attributes.get('inLibrary')
    if (inLibraryAttribute && semver.lt(this.rootElement.$.version, '8.3.0')) {
      inLibraryAttribute._roleProperties.add(isInheritedProperty)
    }
  }

  _addCustomProperties() {
    if (this.rootElement.$.library === undefined && semver.lt(this.rootElement.$.version, '8.2.0')) {
      const recursiveProperty = new SchemaProperty('isInheritedProperty', 'roleProperty')
      this.properties.set('isInheritedProperty', recursiveProperty)
    }
  }
}