Repository URL to install this package:
|
Version:
0.6.0 ▾
|
/*
Copyright (c) 2016, salesforce.com, inc. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/* eslint-disable camelcase */
const _ = require('lodash')
const invariant = require('invariant')
/*
* @typedef {object} Node
* @property {string} type
* @property {string|array} value
* @property {InputStream~Position} start
* @property {InputStream~Position} next
*/
/**
* Convert a @{link TokenStreamProxy} to a @{link Node}
*
* @protected
* @class
*/
class Parser {
/**
* Create a new InputStream
*
* @param {TokenStreamProxy} tokens
*/
constructor (tokens) {
this.tokens = tokens
}
/**
* Return a new @{link Node}
*
* @private
* @param {string} type
* @param {string|array} value
* @param {InputStream~Position} start
* @param {InputStream~Position} next
* @returns {Node}
*/
createNode (type, value, start, next) {
return { type, value, start, next }
}
/**
* Return true if the current token(s) are of the provided type
* and optionally match the specific character(s)
*
* @private
* @param {string} type
* @param {...string} values
* @returns {boolean}
*/
is_type (type, ...values) {
const t = this.tokens.peek()
if (!values.length) return t ? type.test(t.type) : false
return values.reduce((a, c, i) => {
const t = this.tokens.peek(i)
return !t ? false : a && type.test(t.type) && t.value === c
}, true)
}
/**
* Return true if the current token is a space
*
* @private
* @returns {boolean}
*/
is_space () {
return this.is_type(/space/)
}
/**
* Return true if the current token is a comment
*
* @private
* @returns {boolean}
*/
is_comment () {
return this.is_type(/comment/)
}
/**
* Return true if the current token is a punctuation
*
* @private
* @returns {boolean}
*/
is_punctuation () {
return this.is_type(/punctuation/, ...arguments)
}
/**
* Return true if the current token is an operator
*
* @private
* @returns {boolean}
*/
is_operator () {
return this.is_type(/operator/, ...arguments)
}
/**
* Return true if the current token is an identifier
*
* @private
* @returns {boolean}
*/
is_identifier () {
return this.is_type(/identifier/, ...arguments)
}
/**
* Return true if the current token is an atkeyword
*
* @private
* @returns {boolean}
*/
is_atkeyword () {
return this.is_type(/atkeyword/, ...arguments)
}
/**
* Return true if the current tokens are interpolation
*
* @private
* @returns {boolean}
*/
is_interpolation () {
return this.is_punctuation('#', '{')
}
/**
* Return the current and next token if the isType predicate succeeds
*
* @private
* @param {string} type
* @param {function} isType
* @param {...string} chars
* @throws Error
* @returns {boolean}
*/
skip_type (type, isType, ...chars) {
if (isType.apply(this, chars)) {
return { start: this.tokens.peek(), next: this.tokens.next() }
} else {
this.tokens.err(`Expecting ${type}: "${chars.join('')}"`)
}
}
/**
* Expect a punctuation token optionally of the specified type
*
* @private
* @param (...string) chars
* @throws Error
* @returns {boolean}
*/
skip_punctuation () {
return this.skip_type('punctuation', this.is_punctuation, ...arguments)
}
/**
* Expect an operator token optionally of the specified type
*
* @private
* @param (...string) chars
* @throws Error
* @returns {boolean}
*/
skip_operator () {
return this.skip_type('operator', this.is_operator, ...arguments)
}
/**
* Expect an atkeyword token
*
* @private
* @throws Error
* @returns {boolean}
*/
skip_atkeyword () {
return this.skip_type('atkeyword', this.is_atkeyword)
}
/**
* Throw an error at the current token
*
* @private
* @throws Error
*/
unexpected () {
this.tokens.err(`Unexpected token: "${JSON.stringify(this.input.peek())}"`)
}
/**
* Return a top level stylesheet Node
*
* @public
* @returns {Node}
*/
parse_stylesheet () {
const value = []
while (!this.tokens.eof()) {
const node = this.parse_node()
if (_.isArray(node)) {
value.push(...node)
} else {
value.push(node)
}
}
return this.createNode('stylesheet', value)
}
/**
* Parse a top-level Node (atrule,rule,declaration,comment,space)
*
* @private
* @returns {Node|Node[]}
*/
parse_node () {
if (
this.is_space() || this.is_comment()
) return this.tokens.next()
const value = []
const maybe_declaration = (punctuation) => {
let expandedPseudo = false
// If the declaration ends with a ";" expand the first pseudo_class
// because pseudo_class can't be part of a declaration property
if (punctuation === ';') {
const pseudoIndex = _.findIndex(value, {
type: 'pseudo_class'
})
if (pseudoIndex > 0) {
const a = value[pseudoIndex]
const b = this.createNode('punctuation', ':', a.start, _.first(a.value).start)
const nodes = [b].concat(a.value)
value.splice(pseudoIndex, 1, ...nodes)
expandedPseudo = true
}
}
// Try to find a ":"
const puncIndex = _.findIndex(value, {
type: 'punctuation',
value: ':'
})
// If we found a ":"
if (puncIndex >= 0) {
const maybeSpace = value[puncIndex + 1]
// If we found a space, it wasn't a pseudo class selector,
// so parse it as a declaration
// http://www.sassmeister.com/gist/0e60f53033a44b9e5d99362621143059
if (maybeSpace.type === 'space' || expandedPseudo) {
const start = _.first(value).start
let next = _.last(value).next
const property_ = _.take(value, puncIndex)
const propertyNode = this.createNode(
'property', property_, _.first(property_).start, _.last(property_).next)
const value_ = _.drop(value, puncIndex + 1)
if (punctuation === '{') {
const block = this.parse_block()
value_.push(block)
next = block.next
}
const valueNode = this.createNode(
'value', value_, _.first(value_).start, _.last(value_).next)
const declarationValue = [propertyNode, value[puncIndex], valueNode]
if (punctuation === ';') {
const { start } = this.skip_punctuation(';')
declarationValue.push(start)
next = next.start
}
return this.createNode(
'declaration', declarationValue, start, next)
}
}
return false
}
while (!this.tokens.eof()) {
// AtRule
if (this.is_atkeyword()) {
return value.concat(this.parse_at_rule())
}
// Atom
value.push(this.parse_atom())
// Rule
if (this.is_punctuation('{')) {
if (value.length) {
return maybe_declaration('{') || this.parse_rule(value)
} else {
// TODO: throw error?
return value.concat(this.parse_block())
}
}
// Declaration
if (this.is_punctuation(';')) {
return maybe_declaration(';')
}
}
return value
}
/**
* Parse as many atoms as possible while the predicate is true
*
* @private
* @param {function} predicate
* @returns {Node[]}
*/
parse_expression (predicate) {
let value = []
let declaration = []
while (true) {
if (this.tokens.eof() || !predicate()) break
// Declaration
if (this.is_punctuation(':') && declaration.length) {
value.push(this.parse_declaration(declaration))
// Remove the items that are now a declaration
value = _.xor(value, declaration)
declaration = []
}
// Atom
if (this.tokens.eof() || !predicate()) break
const atom = this.parse_atom()
value.push(atom)
// Collect items that might be parsed as a declaration
// $map: ("red": "blue", "hello": "world");
switch (atom.type) {
case 'space':
case 'punctuation':
break
default:
declaration.push(atom)
}
}
return value
}
/**
* Parse a single atom
*
* @private
* @returns {Node}
*/
parse_atom () {
return this.maybe_function(() => {
// Parens
if (this.is_punctuation('(')) {
return this.parse_wrapped('parentheses', '(', ')')
}
// Interpolation
if (this.is_interpolation()) {
return this.parse_interolation()
}
// Attr
if (this.is_punctuation('[')) {
return this.parse_wrapped('attribute', '[', ']')
}
// Class
if (this.is_punctuation('.')) {
return this.parse_selector('class', '.')
}
// Id
if (this.is_punctuation('#')) {
return this.parse_selector('id', '#')
}
// Pseudo Element
if (this.is_punctuation('::')) {
return this.parse_selector('pseudo_element', ':')
}
// Pseudo Class
if (this.is_punctuation(':')) {
const next = this.tokens.peek(1)
if (
(next.type === 'identifier') ||
(next.type === 'punctuation' && next.value === '#')
) {
return this.parse_selector('pseudo_class', ':')
}
}
// Token
return this.tokens.next()
})
}
/**
* Parse a declaration
*
* @private
* @param {Node[]} property
* @returns {Node}
*/
parse_declaration (property) {
const { start: firstSeparator } = this.skip_punctuation(':')
// Expression
let secondSeparator
const value = this.parse_expression(() => {
if (this.is_punctuation(';')) {
secondSeparator = this.tokens.next()
return false
}
if (this.is_punctuation(',')) {
secondSeparator = this.tokens.next()
return false
}
if (this.is_punctuation(')')) return false
return true
})
const propertyNode = this.createNode(
'property', property, _.first(property).start, _.last(property).next)
const valueNode = this.createNode(
'value', value, _.first(value).start, _.last(value).next)
const declarationValue = [propertyNode, firstSeparator, valueNode]
if (secondSeparator) declarationValue.push(secondSeparator)
return this.createNode(
'declaration', declarationValue, _.first(property).start, _.last(value).next)
}
/**
* Parse an expression wrapped in the provided chracters
*
* @private
* @param {string} type
* @param {string} open
* @param {string} close
* @param {InputToken~Position} start
* @returns {Node}
*/
parse_wrapped (type, open, close, _start) {
const { start } = this.skip_punctuation(open)
const value = this.parse_expression(() =>
!this.is_punctuation(close)
)
const { next } = this.skip_punctuation(close)
return this.createNode(type, value, (_start || start).start, next.next)
}
/**
* Parse Nodes wrapped in "{}"
*
* @private
* @returns {Node}
*/
parse_block () {
const { start } = this.skip_punctuation('{')
const value = []
while (
(!this.tokens.eof()) &&
(!this.is_punctuation('}'))
) {
const node = this.parse_node()
if (_.isArray(node)) {
value.push(...node)
} else {
value.push(node)
}
}
const { next } = this.skip_punctuation('}')
// Sass allows blocks to end with semicolons
if (this.is_punctuation(';')) {
this.skip_punctuation(';')
}
return this.createNode('block', value, start.start, next.next)
}
/**
* Parse comma separated expressions wrapped in "()"
*
* @private
* @param {string} [type] the type attrribute of the caller
* @returns {Node}
*/
parse_arguments (type) {
const { start } = this.skip_punctuation('(')
let value = []
if (type === 'pseudo_class') {
while (!this.tokens.eof() && !this.is_punctuation(')')) {
value.push(this.parse_atom())
}
} else {
while (!this.tokens.eof() && !this.is_punctuation(')')) {
value = value.concat(this.parse_expression(() => {
if (this.is_punctuation(',')) return false
if (this.is_punctuation(')')) return false
return true
}))
if (this.is_punctuation(',')) {
value.push(this.tokens.next())
}
}
}
const { next } = this.skip_punctuation(')')
return this.createNode(
'arguments', value, start.start, next.next)
}
/**
* Optionally wrap a node in a "function"
*
* @private
* @param {function} node - returns a node to optionally be wrapped
* @returns {Node}
*/
maybe_function (node) {
node = node()
const types = ['identifier', 'function', 'interpolation', 'pseudo_class']
return this.is_punctuation('(') && _.includes(types, node.type)
? this.parse_function(node) : node
}
/**
* Parse a function node
*
* @private
* @params {Node} node - the node to wrap (usually an identifier)
* @returns {Node}
*/
parse_function (node) {
const args = this.parse_arguments(node.type)
return this.createNode(
'function', [node, args], node.start, args.next)
}
/**
* Parse interpolation
*
* @private
* @returns {Node}
*/
parse_interolation () {
const { start } = this.skip_punctuation('#')
return this.parse_wrapped('interpolation', '{', '}', start)
}
/**
* Parse an atrule
*
* @private
* @returns {Node}
*/
parse_at_rule () {
const { start } = this.skip_atkeyword()
const value = [start]
// Space
if (this.is_space()) value.push(this.tokens.next())
// Identifier (prevent args being converted to a "function")
if (this.is_identifier()) value.push(this.tokens.next())
// Go
while (!this.tokens.eof()) {
if (this.is_punctuation('(') && /mixin|include|function/.test(start.value)) {
value.push(this.parse_arguments())
}
if (this.is_punctuation('{')) {
value.push(this.parse_block())
break
}
if (this.is_punctuation(';')) {
value.push(this.tokens.next())
break
} else {
value.push(this.parse_atom())
}
}
return this.createNode('atrule', value, start.start, _.last(value).next)
}
/**
* Parse a rule
*
* @private
* @param {Node[]} selectors
* @returns {Node}
*/
parse_rule (selectors) {
const selector = this.createNode(
'selector', selectors, _.first(selectors).start, _.last(selectors).next)
const block = this.parse_block()
return this.createNode(
'rule', [selector, block], selector.start, block.next)
}
/**
* Parse selector starting with the provided punctuation
*
* @private
* @param {string} type
* @param {string} punctuation
* @returns {Node}
*/
parse_selector (type, punctuation) {
const { start } = this.skip_punctuation(punctuation)
// Pseudo Element
if (this.is_punctuation(':')) {
this.skip_punctuation(':')
}
const value = []
let next = this.is_interpolation()
? this.parse_interolation() : this.tokens.next()
// Selectors can be a combination of identifiers and interpolation
while (next.type === 'identifier' || next.type === 'interpolation' || next.type === 'operator') {
value.push(next)
next = this.is_interpolation()
? this.parse_interolation() : this.tokens.peek()
if (!next) break
if (next.type === 'identifier') this.tokens.next()
// This is usually a dash following interpolation because identifiers
// can't start with a dash
if (next.type === 'operator') this.tokens.next()
}
if (!value.length) {
this.tokens.err(`Selector ("${type}") expected "identifier" or "interpolation"`)
}
return this.createNode(type, value, start.start, _.last(value).next)
}
}
/**
* @function parseTokenStream
* @private
* @param {TokenStreamProxt} tokenStream
* @returns {TokenStreamProxy}
*/
module.exports = (tokenStream) => {
invariant(
_.isPlainObject(tokenStream) && _.has(tokenStream, 'next'),
'Parser requires a TokenStream'
)
const parser = new Parser(tokenStream)
return parser.parse_stylesheet()
}