Source: parser/parsedHedTag.js

import { IssueError } from '../issues/issues'
import ParsedHedSubstring from './parsedHedSubstring'
import { SchemaValueTag } from '../schema/entries'
import TagConverter from './tagConverter'
import { ReservedChecker } from './reservedChecker'

const TWO_LEVEL_TAGS = new Set(['Definition', 'Def', 'Def-expand'])
const allowedRegEx = /^[^{},]*$/

/**
 * A parsed HED tag.
 */
export default class ParsedHedTag extends ParsedHedSubstring {
  /**
   * The formatted canonical version of the HED tag.
   * @type {string}
   */
  formattedTag

  /**
   * The canonical form of the HED tag.
   * @type {string}
   */
  canonicalTag

  /**
   * The HED schema this tag belongs to.
   * @type {Schema}
   */

  schema
  /**
   * The schema's representation of this tag.
   *
   * @type {SchemaTag}
   * @private
   */

  _schemaTag

  /**
   * The remaining part of the tag after the portion actually in the schema.
   * @type {string}
   * @private
   */
  _remainder

  /**
   * The value of the tag, if any.
   * @type {string}
   * @private
   */
  _value

  /**
   * If definition, this is the second value, otherwise empty string.
   * @type {string}
   * @private
   */
  _splitValue

  /**
   * The units if any.
   * @type {string}
   * @private
   */
  _units

  /**
   * Constructor.
   *
   * @param {TagSpec} tagSpec The token for this tag.
   * @param {Schemas} hedSchemas The collection of HED schemas.
   * @param {string} hedString The original HED string.
   * @throws {IssueError} If tag conversion or parsing fails.
   */
  constructor(tagSpec, hedSchemas, hedString) {
    super(tagSpec.tag, tagSpec.bounds) // Sets originalTag and originalBounds
    this._convertTag(hedSchemas, hedString, tagSpec)
    this._normalized = this.format(false) // Sets various forms of the tag.
  }

  /**
   * Convert this tag to its various forms
   *
   * @param {Schemas} hedSchemas The collection of HED schemas.
   * @param {string} hedString The original HED string.
   * @param {TagSpec} tagSpec The token for this tag.
   * @throws {IssueError} If tag conversion or parsing fails.
   */
  _convertTag(hedSchemas, hedString, tagSpec) {
    const schemaName = tagSpec.library
    this.schema = hedSchemas.getSchema(schemaName)
    if (this.schema === undefined) {
      if (schemaName !== '') {
        IssueError.generateAndThrow('unmatchedLibrarySchema', {
          tag: this.originalTag,
          library: schemaName,
        })
      } else {
        IssueError.generateAndThrow('unmatchedBaseSchema', {
          tag: this.originalTag,
        })
      }
    }

    const [schemaTag, remainder] = new TagConverter(tagSpec, hedSchemas).convert()
    this._schemaTag = schemaTag
    this._remainder = remainder
    this.canonicalTag = this._schemaTag.longExtend(remainder)
    this.formattedTag = this.canonicalTag.toLowerCase()
    this._handleRemainder(schemaTag, remainder)
  }

  /**
   * Handle the remainder portion for value tag (converter handles others).
   *
   * @param {SchemaTag} schemaTag - The part of the tag that is in the schema.
   * @param {string} remainder - the leftover part.
   * @throws {IssueError} If parsing the remainder section fails.
   * @private
   */
  _handleRemainder(schemaTag, remainder) {
    if (!(schemaTag instanceof SchemaValueTag)) {
      return
    }
    // Check that there is a value if required
    const reserved = ReservedChecker.getInstance()
    if ((schemaTag.hasAttribute('requireChild') || reserved.requireValueTags.has(schemaTag.name)) && remainder === '') {
      IssueError.generateAndThrow('valueRequired', { tag: this.originalTag })
    }
    // Check if this could have a two-level value
    const [value, rest] = this._getSplitValue(remainder)
    this._splitValue = rest

    // Resolve the units and check
    const [actualUnit, actualUnitString, actualValueString] = this._separateUnits(schemaTag, value)
    this._units = actualUnitString
    this._value = actualValueString

    if (actualUnit === null && actualUnitString !== null) {
      IssueError.generateAndThrow('unitClassInvalidUnit', { tag: this.originalTag })
    }
    if (!this.checkValue(actualValueString)) {
      IssueError.generateAndThrow('invalidValue', { tag: this.originalTag })
    }
  }

  /**
   * Separate the remainder of the tag into three parts.
   *
   * @param {SchemaTag} schemaTag - The part of the tag that is in the schema.
   * @param {string} remainder - The leftover part.
   * @returns {Array} - [SchemaUnit, string, string] representing the actual Unit, the unit string and the value string.
   * @throws {IssueError} - If parsing the remainder section fails.
   */
  _separateUnits(schemaTag, remainder) {
    const unitClasses = schemaTag.unitClasses
    let actualUnit = null
    let actualUnitString = null
    let actualValueString = remainder // If no unit class, the remainder is the value
    for (let i = 0; i < unitClasses.length; i++) {
      ;[actualUnit, actualUnitString, actualValueString] = unitClasses[i].extractUnit(remainder)
      if (actualUnit !== null) {
        break // found the unit
      }
    }
    return [actualUnit, actualUnitString, actualValueString]
  }

  /**
   * Handle reserved three-level tags.
   * @param {string} remainder - The remainder of the tag string after schema tag.
   */
  _getSplitValue(remainder) {
    if (!TWO_LEVEL_TAGS.has(this.schemaTag.name)) {
      return [remainder, null]
    }
    const [first, ...rest] = remainder.split('/')
    return [first, rest.join('/')]
  }

  /**
   * Nicely format this tag.
   *
   * @param {boolean} long - Whether the tags should be in long form.
   * @returns {string} - The nicely formatted version of this tag.
   */
  format(long = true) {
    let tagName
    if (long) {
      tagName = this._schemaTag?.longExtend(this._remainder)
    } else {
      tagName = this._schemaTag?.extend(this._remainder)
    }
    if (tagName === undefined) {
      tagName = this.originalTag
    }
    if (this.schema?.prefix) {
      return this.schema.prefix + ':' + tagName
    } else {
      return tagName
    }
  }

  /**
   * Return the normalized version of this tag.
   * @returns {string} - The normalized version of this tag.
   */
  get normalized() {
    return this._normalized
  }

  /**
   * Override of {@link Object.prototype.toString}.
   *
   * @returns {string} The original form of this HED tag.
   */
  toString() {
    if (this.schema?.prefix) {
      return this.schema.prefix + ':' + this.originalTag
    } else {
      return this.originalTag
    }
  }

  /**
   * Determine whether this tag has a given attribute.
   *
   * @param {string} attribute An attribute name.
   * @returns {boolean} Whether this tag has the named attribute.
   */
  hasAttribute(attribute) {
    return this.schemaTag.hasAttribute(attribute)
  }

  /**
   * Determine if this HED tag is equivalent to another HED tag.
   *
   * Note: HED tags are deemed equivalent if they have the same schema and normalized tag string.
   *
   * @param {ParsedHedTag} other - A HED tag to compare with this one.
   * @returns {boolean} Whether {@link other} True, if other is equivalent to this HED tag.
   */
  equivalent(other) {
    return other instanceof ParsedHedTag && this.formattedTag === other.formattedTag && this.schema === other.schema
  }

  /**
   * Get the schema tag object for this tag.
   *
   * @returns {SchemaTag} The schema tag object for this tag.
   */
  get schemaTag() {
    if (this._schemaTag instanceof SchemaValueTag) {
      return this._schemaTag.parent
    } else {
      return this._schemaTag
    }
  }

  /**
   * Indicates whether the tag is deprecated
   * @returns {boolean}
   */
  get isDeprecated() {
    return this.schemaTag.hasAttribute('deprecatedFrom')
  }

  /**
   * Indicates whether the tag is deprecated
   * @returns {boolean}
   */
  get isExtended() {
    return !this.takesValueTag && this._remainder !== ''
  }

  /**
   * Get the schema tag object for this tag's value-taking form.
   *
   * @returns {SchemaValueTag} The schema tag object for this tag's value-taking form.
   */
  get takesValueTag() {
    if (this._schemaTag instanceof SchemaValueTag) {
      return this._schemaTag
    }
    return undefined
  }

  /**
   * Checks if this HED tag has the {@code takesValue} attribute.
   *
   * @returns {boolean} Whether this HED tag has the {@code takesValue} attribute.
   */
  get takesValue() {
    return this.takesValueTag !== undefined
  }

  /**
   * Checks if this HED tag has the {@code unitClass} attribute.
   *
   * @returns {boolean} Whether this HED tag has the {@code unitClass} attribute.
   */
  get hasUnitClass() {
    if (!this.takesValueTag) {
      return false
    }
    return this.takesValueTag.hasUnitClasses
  }

  /**
   * Get the unit classes for this HED tag.
   *
   * @returns {SchemaUnitClass[]} The unit classes for this HED tag.
   */
  get unitClasses() {
    if (this.hasUnitClass) {
      return this.takesValueTag.unitClasses
    }
    return []
  }

  /**
   * Check if value is a valid value for this tag.
   *
   * @param {string} value - The value to be checked.
   * @returns {boolean} The result of check -- false if not a valid value.
   */
  checkValue(value) {
    if (!this.takesValue) {
      return false
    }
    if (value === '#') {
      // Placeholders work
      return true
    }
    const valueAttributeNames = this._schemaTag.valueAttributeNames
    const valueClassNames = valueAttributeNames?.get('valueClass')
    if (!valueClassNames) {
      // No specified value classes
      return allowedRegEx.test(value)
    }
    const entryManager = this.schema.entries.valueClasses
    for (let i = 0; i < valueClassNames.length; i++) {
      if (entryManager.getEntry(valueClassNames[i]).validateValue(value)) return true
    }
    return false
  }
}