'use strict'; const Root = require('./root'); const Value = require('./value'); const AtWord = require('./atword'); const Colon = require('./colon'); const Comma = require('./comma'); const Comment = require('./comment'); const Func = require('./function'); const Numbr = require('./number'); const Operator = require('./operator'); const Paren = require('./paren'); const Str = require('./string'); const Word = require('./word'); const UnicodeRange = require('./unicode-range'); const tokenize = require('./tokenize'); const flatten = require('flatten'); const indexesOf = require('indexes-of'); const uniq = require('uniq'); const ParserError = require('./errors/ParserError'); function sortAscending (list) { return list.sort((a, b) => a - b); } module.exports = class Parser { constructor (input, options) { const defaults = { loose: false }; // cache needs to be an array for values with more than 1 level of function nesting this.cache = []; this.input = input; this.options = Object.assign({}, defaults, options); this.position = 0; // we'll use this to keep track of the paren balance this.unbalanced = 0; this.root = new Root(); let value = new Value(); this.root.append(value); this.current = value; this.tokens = tokenize(input, this.options); } parse () { return this.loop(); } colon () { let token = this.currToken; this.newNode(new Colon({ value: token[1], source: { start: { line: token[2], column: token[3] }, end: { line: token[4], column: token[5] } }, sourceIndex: token[6] })); this.position ++; } comma () { let token = this.currToken; this.newNode(new Comma({ value: token[1], source: { start: { line: token[2], column: token[3] }, end: { line: token[4], column: token[5] } }, sourceIndex: token[6] })); this.position ++; } comment () { let inline = false, value = this.currToken[1].replace(/\/\*|\*\//g, ''), node; if (this.options.loose && value.startsWith("//")) { value = value.substring(2); inline = true; } node = new Comment({ value: value, inline: inline, source: { start: { line: this.currToken[2], column: this.currToken[3] }, end: { line: this.currToken[4], column: this.currToken[5] } }, sourceIndex: this.currToken[6] }); this.newNode(node); this.position++; } error (message, token) { throw new ParserError(message + ` at line: ${token[2]}, column ${token[3]}`); } loop () { while (this.position < this.tokens.length) { this.parseTokens(); } if (!this.current.last && this.spaces) { this.current.raws.before += this.spaces; } else if (this.spaces) { this.current.last.raws.after += this.spaces; } this.spaces = ''; return this.root; } operator () { // if a +|- operator is followed by a non-word character (. is allowed) and // is preceded by a non-word character. (5+5) let char = this.currToken[1], node; if (char === '+' || char === '-') { // only inspect if the operator is not the first token, and we're only // within a calc() function: the only spec-valid place for math expressions if (!this.options.loose) { if (this.position > 0) { if (this.current.type === 'func' && this.current.value === 'calc') { // allow operators to be proceeded by spaces and opening parens if (this.prevToken[0] !== 'space' && this.prevToken[0] !== '(') { this.error('Syntax Error', this.currToken); } // valid: calc(1 - +2) // invalid: calc(1 -+2) else if (this.nextToken[0] !== 'space' && this.nextToken[0] !== 'word') { this.error('Syntax Error', this.currToken); } // valid: calc(1 - +2) // valid: calc(-0.5 + 2) // invalid: calc(1 -2) else if (this.nextToken[0] === 'word' && this.current.last.type !== 'operator' && this.current.last.value !== '(') { this.error('Syntax Error', this.currToken); } } // if we're not in a function and someone has doubled up on operators, // or they're trying to perform a calc outside of a calc // eg. +-4px or 5+ 5, throw an error else if (this.nextToken[0] === 'space' || this.nextToken[0] === 'operator' || this.prevToken[0] === 'operator') { this.error('Syntax Error', this.currToken); } } } if (!this.options.loose) { if (this.nextToken[0] === 'word') { return this.word(); } } else { if ((!this.current.nodes.length || (this.current.last && this.current.last.type === 'operator')) && this.nextToken[0] === 'word') { return this.word(); } } } node = new Operator({ value: this.currToken[1], source: { start: { line: this.currToken[2], column: this.currToken[3] }, end: { line: this.currToken[2], column: this.currToken[3] } }, sourceIndex: this.currToken[4] }); this.position ++; return this.newNode(node); } parseTokens () { switch (this.currToken[0]) { case 'space': this.space(); break; case 'colon': this.colon(); break; case 'comma': this.comma(); break; case 'comment': this.comment(); break; case '(': this.parenOpen(); break; case ')': this.parenClose(); break; case 'atword': case 'word': this.word(); break; case 'operator': this.operator(); break; case 'string': this.string(); break; case 'unicoderange': this.unicodeRange(); break; default: this.word(); break; } } parenOpen () { let unbalanced = 1, pos = this.position + 1, token = this.currToken, last; // check for balanced parens while (pos < this.tokens.length && unbalanced) { let tkn = this.tokens[pos]; if (tkn[0] === '(') { unbalanced++; } if (tkn[0] === ')') { unbalanced--; } pos ++; } if (unbalanced) { this.error('Expected closing parenthesis', token); } // ok, all parens are balanced. continue on last = this.current.last; if (last && last.type === 'func' && last.unbalanced < 0) { last.unbalanced = 0; // ok we're ready to add parens now this.current = last; } this.current.unbalanced ++; this.newNode(new Paren({ value: token[1], source: { start: { line: token[2], column: token[3] }, end: { line: token[4], column: token[5] } }, sourceIndex: token[6] })); this.position ++; // url functions get special treatment, and anything between the function // parens get treated as one word, if the contents aren't not a string. if (this.current.type === 'func' && this.current.unbalanced && this.current.value === 'url' && this.currToken[0] !== 'string' && this.currToken[0] !== ')' && !this.options.loose) { let nextToken = this.nextToken, value = this.currToken[1], start = { line: this.currToken[2], column: this.currToken[3] }; while (nextToken && nextToken[0] !== ')' && this.current.unbalanced) { this.position ++; value += this.currToken[1]; nextToken = this.nextToken; } if (this.position !== this.tokens.length - 1) { // skip the following word definition, or it'll be a duplicate this.position ++; this.newNode(new Word({ value, source: { start, end: { line: this.currToken[4], column: this.currToken[5] } }, sourceIndex: this.currToken[6] })); } } } parenClose () { let token = this.currToken; this.newNode(new Paren({ value: token[1], source: { start: { line: token[2], column: token[3] }, end: { line: token[4], column: token[5] } }, sourceIndex: token[6] })); this.position ++; if (this.position >= this.tokens.length - 1 && !this.current.unbalanced) { return; } this.current.unbalanced --; if (this.current.unbalanced < 0) { this.error('Expected opening parenthesis', token); } if (!this.current.unbalanced && this.cache.length) { this.current = this.cache.pop(); } } space () { let token = this.currToken; // Handle space before and after the selector if (this.position === (this.tokens.length - 1) || this.nextToken[0] === ',' || this.nextToken[0] === ')') { this.current.last.raws.after += token[1]; this.position ++; } else { this.spaces = token[1]; this.position ++; } } unicodeRange () { let token = this.currToken; this.newNode(new UnicodeRange({ value: token[1], source: { start: { line: token[2], column: token[3] }, end: { line: token[4], column: token[5] } }, sourceIndex: token[6] })); this.position ++; } splitWord () { let nextToken = this.nextToken, word = this.currToken[1], rNumber = /^[\+\-]?((\d+(\.\d*)?)|(\.\d+))([eE][\+\-]?\d+)?/, // treat css-like groupings differently so they can be inspected, // but don't address them as anything but a word, but allow hex values // to pass through. rNoFollow = /^(?!\#([a-z0-9]+))[\#\{\}]/gi, hasAt, indices; if (!rNoFollow.test(word)) { while (nextToken && nextToken[0] === 'word') { this.position ++; let current = this.currToken[1]; word += current; nextToken = this.nextToken; } } hasAt = indexesOf(word, '@'); indices = sortAscending(uniq(flatten([[0], hasAt]))); indices.forEach((ind, i) => { let index = indices[i + 1] || word.length, value = word.slice(ind, index), node; if (~hasAt.indexOf(ind)) { node = new AtWord({ value: value.slice(1), source: { start: { line: this.currToken[2], column: this.currToken[3] + ind }, end: { line: this.currToken[4], column: this.currToken[3] + (index - 1) } }, sourceIndex: this.currToken[6] + indices[i] }); } else if (rNumber.test(this.currToken[1])) { let unit = value.replace(rNumber, ''); node = new Numbr({ value: value.replace(unit, ''), source: { start: { line: this.currToken[2], column: this.currToken[3] + ind }, end: { line: this.currToken[4], column: this.currToken[3] + (index - 1) } }, sourceIndex: this.currToken[6] + indices[i], unit }); } else { node = new (nextToken && nextToken[0] === '(' ? Func : Word)({ value, source: { start: { line: this.currToken[2], column: this.currToken[3] + ind }, end: { line: this.currToken[4], column: this.currToken[3] + (index - 1) } }, sourceIndex: this.currToken[6] + indices[i] }); if (node.constructor.name === 'Word') { node.isHex = /^#(.+)/.test(value); node.isColor = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value); } else { this.cache.push(this.current); } } this.newNode(node); }); this.position ++; } string () { let token = this.currToken, value = this.currToken[1], rQuote = /^(\"|\')/, quoted = rQuote.test(value), quote = '', node; if (quoted) { quote = value.match(rQuote)[0]; // set value to the string within the quotes // quotes are stored in raws value = value.slice(1, value.length - 1); } node = new Str({ value, source: { start: { line: token[2], column: token[3] }, end: { line: token[4], column: token[5] } }, sourceIndex: token[6], quoted }); node.raws.quote = quote; this.newNode(node); this.position++; } word () { return this.splitWord(); } newNode (node) { if (this.spaces) { node.raws.before += this.spaces; this.spaces = ''; } return this.current.append(node); } get currToken () { return this.tokens[this.position]; } get nextToken () { return this.tokens[this.position + 1]; } get prevToken () { return this.tokens[this.position - 1]; } };