rofrischmann/elodin

View on GitHub
core/parser/src/Parser.js

Summary

Maintainability
F
1 wk
Test Coverage
import tokenize from 'tokenize-sync'
import color from 'color'

import validateDeclaration from './validateDeclaration'
import validateFunction from './validateFunction'
import errorTypes from './errorTypes'
import colorNames from './colorNames'

const ruleMap = {
  minus: /^[-]$/,
  comment: /^[#].*$/,
  comparison: /^[><=]+$/i,
  quote: /^("|\\")$/,
  identifier: /^[_a-z]+$/i,
  number: /^\d+$/,
  floating_point: /^[.]+$/,
  colon: /^:$/,
  whitespace: /^\s+$/,
  round_bracket: /^[()]$/,
  curly_bracket: /^[{}]$/,
  square_bracket: /^[\[\]]$/,
  variable: /^[$@]$/,
}

const OPERATOR_REGEX = /^(>|<|>=|<=|=)$/

export default class Parser {
  constructor(config = {}) {
    this.config = config
    this.context = config.context || {}
  }

  getNextToken(position) {
    const nextPosition = this.currentPosition + position

    if (nextPosition < this.tokens.length) {
      return this.tokens[nextPosition]
    }
  }

  updateCurrentToken(increment = 0) {
    this.currentPosition += increment
    this.currentToken = this.tokens[this.currentPosition]

    // ensure we always skip comments
    this.parseComment()
  }

  isRunning() {
    return this.currentPosition < this.tokens.length - 1
  }

  addError(error, exit) {
    if (!this.exit) {
      const meta = {
        ...(this.parent || {}),
        path: this.context.path,
        source: this.context.source,
        line: this.context.source
          ? this.context.source.substr(0, this.currentToken.start).split('\n')
              .length
          : undefined,
        token: this.currentToken,
      }

      this.errors.push({ ...meta, ...error })
    }

    if (exit) {
      this.currentPosition = this.tokens.length - 1
      this.updateCurrentToken()
      this.exit = true
    }
  }

  getComments() {
    this.parseComment()
    const comments = [...this.comments]
    this.comments = []

    return comments
  }

  parse(input) {
    this.tokens = tokenize(input.trim(), ruleMap, 'unknown').filter(
      (token) => token.type !== 'whitespace'
    )

    this.tokens.push({
      type: 'end_token',
    })

    // initialize/reset indices
    this.input = input
    this.currentPosition = 0
    this.errors = []
    this.variantConditionals = []
    this.comments = []
    this.updateCurrentToken()

    const file = {
      type: 'File',
      body: [],
    }

    while (this.isRunning()) {
      this.parent = undefined

      const comments = this.getComments()
      const node = this.parseStyle() || this.parseVariant()

      if (node) {
        const duplicate = file.body.find((n) => n.name === node.name)

        if (duplicate) {
          this.updateCurrentToken(1)

          this.addError(
            {
              type: errorTypes.DUPLICATE_MODULE,
              message: `The ${node.type.toLowerCase()} '${
                node.name
              }' has already been defined as a ${
                duplicate.type
              } in the same file.`,
              node,
              duplicate,
            },
            true
          )
        }
      }

      if (!node) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message:
              'Invalid Syntax. Top-level constructs can only be style or variant.',
          },
          true
        )
      }

      file.body.push({ ...node, comments })
    }

    const variants = file.body.filter((node) => node.type === 'Variant')
    this.variantConditionals.forEach(({ property, value }) => {
      const matchingVariant = variants.find((v) => v.name === property.value)

      if (!matchingVariant) {
        this.addError({
          type: errorTypes.INVALID_VARIANT,
          message: `The variant '${property.value}' is not defined.`,
        })
      } else {
        const matchingValue = matchingVariant.body.find(
          (v) => v.value === value.value
        )

        if (!matchingValue) {
          this.addError({
            type: errorTypes.INVALID_VARIANT,
            message: `The variant '${property.value}' does not define a '${value.value}' variation.`,
          })
        }
      }
    })

    return {
      errors: this.errors,
      tokens: this.tokens,
      ast: file,
    }
  }

  parseComment() {
    if (this.currentToken && this.currentToken.type === 'comment') {
      this.comments.push(this.currentToken.value.substr(1))
      this.updateCurrentToken(1)
    }
  }

  parseStyle() {
    if (
      this.currentToken.type === 'identifier' &&
      this.currentToken.value === 'style'
    ) {
      this.updateCurrentToken(1)

      const name = this.parseStyleName()

      if (!name) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message: 'A style must have a valid name.',
          },
          true
        )
      }

      if (name && name.charAt(0).toUpperCase() !== name.charAt(0)) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message: 'A style name must begin with an uppercase letter.',
            name,
          },
          true
        )
      }

      this.parent = {
        type: 'Style',
        name,
      }

      const body = this.parseStyleBody()

      if (!body) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message: 'A style must at least contain 1 declaration.',
          },
          true
        )
      }

      return {
        ...this.parent,
        body,
      }
    }
  }

  parseStyleName() {
    if (this.currentToken.type === 'identifier') {
      const name = this.currentToken.value
      this.updateCurrentToken(1)
      return name
    }
  }

  parseStyleBody() {
    const body = []

    if (
      this.currentToken.type === 'curly_bracket' &&
      this.currentToken.value === '{'
    ) {
      this.updateCurrentToken(1)

      while (this.isRunning() && this.currentToken.type !== 'curly_bracket') {
        const comments = this.getComments()
        const node = this.parseDeclaration() || this.parseConditional()

        if (!node) {
          this.addError(
            {
              token: this.getNextToken(-1),
              type: errorTypes.SYNTAX_ERROR,
              message:
                'A style must only contain declarations and conditional declarations.',
            },
            true
          )

          return
        }

        const duplicate = body.find((n) => n.property === node.property)

        if (duplicate) {
          this.addError(
            {
              type: errorTypes.DUPLICATE_PROPERTY,
              message: `The property ${duplicate.property} has already been declared before.`,
              duplicate,
            },
            true
          )
        }

        body.push({ ...node, comments })
        this.updateCurrentToken(1)
      }

      this.updateCurrentToken(1)
      return body
    }
  }

  parseVariant() {
    if (
      this.currentToken.type === 'identifier' &&
      this.currentToken.value === 'variant'
    ) {
      this.updateCurrentToken(1)

      const name = this.parseStyleName()

      if (!name) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message: 'A variant must have a valid name.',
          },
          true
        )
      }

      if (name && name.charAt(0).toUpperCase() !== name.charAt(0)) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message: 'Variant names must begin with an uppercase letter.',
            name,
          },
          true
        )
      }

      this.parent = {
        type: 'variant',
        name,
      }

      const body = this.parseVariantBody()

      if (!body) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message: 'A variant must at least have 1 variation.',
          },
          true
        )
      }

      return {
        type: 'Variant',
        name,
        body,
      }
    }
  }

  parseVariantBody() {
    const body = []

    if (
      this.currentToken.type === 'curly_bracket' &&
      this.currentToken.value === '{'
    ) {
      this.updateCurrentToken(1)

      while (this.isRunning() && this.currentToken.type !== 'curly_bracket') {
        const comments = this.getComments()
        const variant = this.parseIdentifier()

        if (!variant) {
          this.addError(
            {
              type: errorTypes.SYNTAX_ERROR,
              message: 'Variants must only contain identifier variations.',
            },
            true
          )
        }

        if (
          variant &&
          variant.value.charAt(0).toUpperCase() !== variant.value.charAt(0)
        ) {
          this.addError(
            {
              type: errorTypes.SYNTAX_ERROR,
              message: 'Variations must begin with an uppercase letter.',
              variation: variant.value,
            },
            true
          )
        }

        const duplicate = body.find((v) => v.value === variant.value)
        if (duplicate) {
          this.addError(
            {
              type: errorTypes.DUPLICATE_VARIANT,
              message: `The variation value ${duplicate.value} has already been declared before.`,
              variation: variant.value,
              duplicate,
            },
            true
          )
        }

        body.push({ ...variant, comments })
        this.updateCurrentToken(1)
      }

      this.updateCurrentToken(1)
      return body
    }
  }

  parseDeclaration() {
    if (this.currentToken.type === 'identifier') {
      const propertyToken = this.currentToken
      const property = propertyToken.value
      const isRawDeclaration = property.substr(0, 2) === '__'

      this.updateCurrentToken(1)

      if (this.currentToken.type === 'colon') {
        this.updateCurrentToken(1)
        this.isDynamic = false

        const valueToken = this.currentToken
        const value = this.parseValue()

        if (!value) {
          return this.addError(
            {
              type: errorTypes.SYNTAX_ERROR,
              message: 'A declaration must always have a valid value.',
            },
            true
          )
        }

        if (!isRawDeclaration) {
          const validation = validateDeclaration(property, value, value.value)

          if (typeof validation === 'object') {
            if (validation.type === 'property') {
              this.addError(
                {
                  type: errorTypes.INVALID_PROPERTY,
                  property: property,
                  value: value,
                  hint: validation.hint,

                  message:
                    property +
                    ': ' +
                    value.value +
                    '\n^-------\n' +
                    'The property ' +
                    property +
                    ' is an invalid style property.' +
                    '\n' +
                    'In ' +
                    this.parent.name +
                    (this.context.path
                      ? ' (' +
                        this.context.path +
                        ':' +
                        this.context.source
                          .substr(0, this.currentToken.start)
                          .split('\n').length +
                        ')'
                      : '') +
                    '\n',
                },
                false
              )
            }

            if (
              validation.type === 'value' &&
              value.type !== 'Variable' &&
              value.type !== 'RawValue'
            ) {
              this.addError(
                {
                  type: errorTypes.INVALID_VALUE,
                  value: value,
                  property: property,
                  hint: validation.hint,
                  message:
                    property +
                    ': ' +
                    value.value +
                    '\n' +
                    ' '.repeat(property.length + 2) +
                    '^-------\n' +
                    'The value ' +
                    value.value +
                    ' is an invalid value for the property ' +
                    property +
                    '.\n' +
                    'In ' +
                    this.parent.name +
                    (this.context.path
                      ? ' (' +
                        this.context.path +
                        ':' +
                        this.context.source
                          .substr(0, this.currentToken.start)
                          .split('\n').length +
                        ')'
                      : '') +
                    '\n',
                },
                false
              )
            }
          }
        }

        return {
          type: 'Declaration',
          raw: isRawDeclaration,
          dynamic: this.isDynamic,
          property: isRawDeclaration ? property.slice(2) : property,
          value,
        }
      }
    }
  }

  parseConditional() {
    if (
      this.currentToken.type === 'square_bracket' &&
      this.currentToken.value === '['
    ) {
      this.updateCurrentToken(1)

      const condition = this.parseCondition()

      if (!condition) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message: 'Conditional delcarations must have a valid condition.',
          },
          true
        )
      }

      const body = this.parseStyleBody()

      if (!body) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message:
              'Conditional declarations must at least contain 1 declaration.',
          },
          true
        )
      }

      this.updateCurrentToken(-1)

      if (!condition.boolean && condition.property.type === 'Identifier') {
        this.variantConditionals.push(condition)
      }

      return {
        ...condition,
        type: 'Conditional',
        body,
      }
    }
  }

  parseCondition() {
    const property = this.parseIdentifier() || this.parseVariable()

    if (property) {
      this.updateCurrentToken(1)

      if (this.currentToken.type === 'comparison') {
        const operator = this.currentToken.value

        if (operator.match(OPERATOR_REGEX) === null) {
          this.addError(
            {
              type: errorTypes.SYNTAX_ERROR,
              message: 'A condition must have a valid comparison operator.',
              operator,
            },
            true
          )

          return
        }

        if (property.type === 'Identifier' && operator !== '=') {
          this.addError(
            {
              type: errorTypes.SYNTAX_ERROR,
              message: 'Variant conditionals can only use equal comparison.',
              property,
              operator,
            },
            true
          )

          return
        }

        this.updateCurrentToken(1)
        const value = this.parseValue()
        this.updateCurrentToken(1)

        if (
          this.currentToken.type === 'square_bracket' &&
          this.currentToken.value === ']'
        ) {
          this.updateCurrentToken(1)

          return {
            property,
            operator,
            value,
          }
        }
      }

      // boolean conditions
      if (
        this.currentToken.type === 'square_bracket' &&
        this.currentToken.value === ']'
      ) {
        this.updateCurrentToken(1)

        return {
          property,
          boolean: true,
        }
      }
    }
  }

  parseValue() {
    if (this.currentToken.type === 'minus') {
      this.updateCurrentToken(1)
      return this.parseNumber(true)
    }

    return (
      this.parseNumber() ||
      this.parseIdentifier() ||
      this.parseVariable() ||
      this.parseString()
    )
  }

  parseString() {
    if (this.currentToken.type === 'quote') {
      let value = ''
      this.updateCurrentToken(1)

      while (this.isRunning() && this.currentToken.type !== 'quote') {
        // parse whitespace sensitive
        value +=
          ' '.repeat(this.currentToken.start - this.getNextToken(-1).end) +
          this.currentToken.value
        this.updateCurrentToken(1)
      }

      return {
        type: 'String',
        value,
      }
    }
  }

  parseNumber(isNegative = false) {
    if (this.currentToken.type === 'number') {
      const integer = this.currentToken.value

      const nextToken = this.getNextToken(1)
      const nextNextToken = this.getNextToken(2)

      if (
        nextToken.type === 'floating_point' &&
        nextNextToken.type === 'number'
      ) {
        this.updateCurrentToken(2)

        return {
          type: 'Float',
          integer: parseInt(integer),
          fractional: parseInt(nextNextToken.value),
          negative: isNegative,
        }
      }

      return {
        type: 'Integer',
        value: parseInt(integer),
        negative: isNegative,
      }
    }

    if (this.currentToken.type === 'floating_point') {
      this.updateCurrentToken(1)

      if (this.currentToken.type === 'number') {
        return {
          type: 'Float',
          integer: 0,
          fractional: parseInt(this.currentToken.value),
          negative: isNegative,
        }
      }
    }
  }

  parseUntil(until, cummulate = false) {
    const values = []

    while (this.isRunning() && !until(this.currentToken)) {
      const value = this.parseValue()

      if (!value) {
        this.addError(
          {
            type: errorTypes.SYNTAX_ERROR,
            message:
              "The value couldn't be parsed. Maybe you're missing closing parenthesis.",
          },
          true
        )
      }

      values.push(value)
      this.updateCurrentToken(1)
    }

    return values
  }

  parseIdentifier() {
    if (this.currentToken.type === 'identifier') {
      const ident = this.currentToken.value
      const nextToken = this.getNextToken(1)
      if (nextToken.type === 'round_bracket' && nextToken.value === '(') {
        this.updateCurrentToken(2)

        const params = this.parseUntil(
          (token) => token.type === 'round_bracket'
        )

        const validation = validateFunction(ident, params)

        if (typeof validation === 'object') {
          this.addError(
            {
              ...validation,
              type: 'INVALID_FUNCTION',
            },
            false
          )
        }

        return {
          type: 'FunctionExpression',
          callee: ident,
          params,
        }
      }

      return {
        type: 'Identifier',
        value: ident,
      }
    }
  }

  parseVariable() {
    if (this.currentToken.type === 'variable') {
      const isEnv = this.currentToken.value === '@'

      this.updateCurrentToken(1)

      if (this.currentToken.type === 'identifier') {
        this.isDynamic = true
        return {
          type: 'Variable',
          value: this.currentToken.value,
          environment: isEnv,
        }
      }
    }
  }
}