612 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			612 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict'
 | |
| 
 | |
| let AtRule = require('./at-rule')
 | |
| let Comment = require('./comment')
 | |
| let Declaration = require('./declaration')
 | |
| let Root = require('./root')
 | |
| let Rule = require('./rule')
 | |
| let tokenizer = require('./tokenize')
 | |
| 
 | |
| const SAFE_COMMENT_NEIGHBOR = {
 | |
|   empty: true,
 | |
|   space: true
 | |
| }
 | |
| 
 | |
| function findLastWithPosition(tokens) {
 | |
|   for (let i = tokens.length - 1; i >= 0; i--) {
 | |
|     let token = tokens[i]
 | |
|     let pos = token[3] || token[2]
 | |
|     if (pos) return pos
 | |
|   }
 | |
| }
 | |
| 
 | |
| class Parser {
 | |
|   constructor(input) {
 | |
|     this.input = input
 | |
| 
 | |
|     this.root = new Root()
 | |
|     this.current = this.root
 | |
|     this.spaces = ''
 | |
|     this.semicolon = false
 | |
| 
 | |
|     this.createTokenizer()
 | |
|     this.root.source = { input, start: { column: 1, line: 1, offset: 0 } }
 | |
|   }
 | |
| 
 | |
|   atrule(token) {
 | |
|     let node = new AtRule()
 | |
|     node.name = token[1].slice(1)
 | |
|     if (node.name === '') {
 | |
|       this.unnamedAtrule(node, token)
 | |
|     }
 | |
|     this.init(node, token[2])
 | |
| 
 | |
|     let type
 | |
|     let prev
 | |
|     let shift
 | |
|     let last = false
 | |
|     let open = false
 | |
|     let params = []
 | |
|     let brackets = []
 | |
| 
 | |
|     while (!this.tokenizer.endOfFile()) {
 | |
|       token = this.tokenizer.nextToken()
 | |
|       type = token[0]
 | |
| 
 | |
|       if (type === '(' || type === '[') {
 | |
|         brackets.push(type === '(' ? ')' : ']')
 | |
|       } else if (type === '{' && brackets.length > 0) {
 | |
|         brackets.push('}')
 | |
|       } else if (type === brackets[brackets.length - 1]) {
 | |
|         brackets.pop()
 | |
|       }
 | |
| 
 | |
|       if (brackets.length === 0) {
 | |
|         if (type === ';') {
 | |
|           node.source.end = this.getPosition(token[2])
 | |
|           node.source.end.offset++
 | |
|           this.semicolon = true
 | |
|           break
 | |
|         } else if (type === '{') {
 | |
|           open = true
 | |
|           break
 | |
|         } else if (type === '}') {
 | |
|           if (params.length > 0) {
 | |
|             shift = params.length - 1
 | |
|             prev = params[shift]
 | |
|             while (prev && prev[0] === 'space') {
 | |
|               prev = params[--shift]
 | |
|             }
 | |
|             if (prev) {
 | |
|               node.source.end = this.getPosition(prev[3] || prev[2])
 | |
|               node.source.end.offset++
 | |
|             }
 | |
|           }
 | |
|           this.end(token)
 | |
|           break
 | |
|         } else {
 | |
|           params.push(token)
 | |
|         }
 | |
|       } else {
 | |
|         params.push(token)
 | |
|       }
 | |
| 
 | |
|       if (this.tokenizer.endOfFile()) {
 | |
|         last = true
 | |
|         break
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     node.raws.between = this.spacesAndCommentsFromEnd(params)
 | |
|     if (params.length) {
 | |
|       node.raws.afterName = this.spacesAndCommentsFromStart(params)
 | |
|       this.raw(node, 'params', params)
 | |
|       if (last) {
 | |
|         token = params[params.length - 1]
 | |
|         node.source.end = this.getPosition(token[3] || token[2])
 | |
|         node.source.end.offset++
 | |
|         this.spaces = node.raws.between
 | |
|         node.raws.between = ''
 | |
|       }
 | |
|     } else {
 | |
|       node.raws.afterName = ''
 | |
|       node.params = ''
 | |
|     }
 | |
| 
 | |
|     if (open) {
 | |
|       node.nodes = []
 | |
|       this.current = node
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   checkMissedSemicolon(tokens) {
 | |
|     let colon = this.colon(tokens)
 | |
|     if (colon === false) return
 | |
| 
 | |
|     let founded = 0
 | |
|     let token
 | |
|     for (let j = colon - 1; j >= 0; j--) {
 | |
|       token = tokens[j]
 | |
|       if (token[0] !== 'space') {
 | |
|         founded += 1
 | |
|         if (founded === 2) break
 | |
|       }
 | |
|     }
 | |
|     // If the token is a word, e.g. `!important`, `red` or any other valid property's value.
 | |
|     // Then we need to return the colon after that word token. [3] is the "end" colon of that word.
 | |
|     // And because we need it after that one we do +1 to get the next one.
 | |
|     throw this.input.error(
 | |
|       'Missed semicolon',
 | |
|       token[0] === 'word' ? token[3] + 1 : token[2]
 | |
|     )
 | |
|   }
 | |
| 
 | |
|   colon(tokens) {
 | |
|     let brackets = 0
 | |
|     let prev, token, type
 | |
|     for (let [i, element] of tokens.entries()) {
 | |
|       token = element
 | |
|       type = token[0]
 | |
| 
 | |
|       if (type === '(') {
 | |
|         brackets += 1
 | |
|       }
 | |
|       if (type === ')') {
 | |
|         brackets -= 1
 | |
|       }
 | |
|       if (brackets === 0 && type === ':') {
 | |
|         if (!prev) {
 | |
|           this.doubleColon(token)
 | |
|         } else if (prev[0] === 'word' && prev[1] === 'progid') {
 | |
|           continue
 | |
|         } else {
 | |
|           return i
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       prev = token
 | |
|     }
 | |
|     return false
 | |
|   }
 | |
| 
 | |
|   comment(token) {
 | |
|     let node = new Comment()
 | |
|     this.init(node, token[2])
 | |
|     node.source.end = this.getPosition(token[3] || token[2])
 | |
|     node.source.end.offset++
 | |
| 
 | |
|     let text = token[1].slice(2, -2)
 | |
|     if (/^\s*$/.test(text)) {
 | |
|       node.text = ''
 | |
|       node.raws.left = text
 | |
|       node.raws.right = ''
 | |
|     } else {
 | |
|       let match = text.match(/^(\s*)([^]*\S)(\s*)$/)
 | |
|       node.text = match[2]
 | |
|       node.raws.left = match[1]
 | |
|       node.raws.right = match[3]
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   createTokenizer() {
 | |
|     this.tokenizer = tokenizer(this.input)
 | |
|   }
 | |
| 
 | |
|   decl(tokens, customProperty) {
 | |
|     let node = new Declaration()
 | |
|     this.init(node, tokens[0][2])
 | |
| 
 | |
|     let last = tokens[tokens.length - 1]
 | |
|     if (last[0] === ';') {
 | |
|       this.semicolon = true
 | |
|       tokens.pop()
 | |
|     }
 | |
| 
 | |
|     node.source.end = this.getPosition(
 | |
|       last[3] || last[2] || findLastWithPosition(tokens)
 | |
|     )
 | |
|     node.source.end.offset++
 | |
| 
 | |
|     while (tokens[0][0] !== 'word') {
 | |
|       if (tokens.length === 1) this.unknownWord(tokens)
 | |
|       node.raws.before += tokens.shift()[1]
 | |
|     }
 | |
|     node.source.start = this.getPosition(tokens[0][2])
 | |
| 
 | |
|     node.prop = ''
 | |
|     while (tokens.length) {
 | |
|       let type = tokens[0][0]
 | |
|       if (type === ':' || type === 'space' || type === 'comment') {
 | |
|         break
 | |
|       }
 | |
|       node.prop += tokens.shift()[1]
 | |
|     }
 | |
| 
 | |
|     node.raws.between = ''
 | |
| 
 | |
|     let token
 | |
|     while (tokens.length) {
 | |
|       token = tokens.shift()
 | |
| 
 | |
|       if (token[0] === ':') {
 | |
|         node.raws.between += token[1]
 | |
|         break
 | |
|       } else {
 | |
|         if (token[0] === 'word' && /\w/.test(token[1])) {
 | |
|           this.unknownWord([token])
 | |
|         }
 | |
|         node.raws.between += token[1]
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (node.prop[0] === '_' || node.prop[0] === '*') {
 | |
|       node.raws.before += node.prop[0]
 | |
|       node.prop = node.prop.slice(1)
 | |
|     }
 | |
| 
 | |
|     let firstSpaces = []
 | |
|     let next
 | |
|     while (tokens.length) {
 | |
|       next = tokens[0][0]
 | |
|       if (next !== 'space' && next !== 'comment') break
 | |
|       firstSpaces.push(tokens.shift())
 | |
|     }
 | |
| 
 | |
|     this.precheckMissedSemicolon(tokens)
 | |
| 
 | |
|     for (let i = tokens.length - 1; i >= 0; i--) {
 | |
|       token = tokens[i]
 | |
|       if (token[1].toLowerCase() === '!important') {
 | |
|         node.important = true
 | |
|         let string = this.stringFrom(tokens, i)
 | |
|         string = this.spacesFromEnd(tokens) + string
 | |
|         if (string !== ' !important') node.raws.important = string
 | |
|         break
 | |
|       } else if (token[1].toLowerCase() === 'important') {
 | |
|         let cache = tokens.slice(0)
 | |
|         let str = ''
 | |
|         for (let j = i; j > 0; j--) {
 | |
|           let type = cache[j][0]
 | |
|           if (str.trim().startsWith('!') && type !== 'space') {
 | |
|             break
 | |
|           }
 | |
|           str = cache.pop()[1] + str
 | |
|         }
 | |
|         if (str.trim().startsWith('!')) {
 | |
|           node.important = true
 | |
|           node.raws.important = str
 | |
|           tokens = cache
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       if (token[0] !== 'space' && token[0] !== 'comment') {
 | |
|         break
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     let hasWord = tokens.some(i => i[0] !== 'space' && i[0] !== 'comment')
 | |
| 
 | |
|     if (hasWord) {
 | |
|       node.raws.between += firstSpaces.map(i => i[1]).join('')
 | |
|       firstSpaces = []
 | |
|     }
 | |
|     this.raw(node, 'value', firstSpaces.concat(tokens), customProperty)
 | |
| 
 | |
|     if (node.value.includes(':') && !customProperty) {
 | |
|       this.checkMissedSemicolon(tokens)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   doubleColon(token) {
 | |
|     throw this.input.error(
 | |
|       'Double colon',
 | |
|       { offset: token[2] },
 | |
|       { offset: token[2] + token[1].length }
 | |
|     )
 | |
|   }
 | |
| 
 | |
|   emptyRule(token) {
 | |
|     let node = new Rule()
 | |
|     this.init(node, token[2])
 | |
|     node.selector = ''
 | |
|     node.raws.between = ''
 | |
|     this.current = node
 | |
|   }
 | |
| 
 | |
|   end(token) {
 | |
|     if (this.current.nodes && this.current.nodes.length) {
 | |
|       this.current.raws.semicolon = this.semicolon
 | |
|     }
 | |
|     this.semicolon = false
 | |
| 
 | |
|     this.current.raws.after = (this.current.raws.after || '') + this.spaces
 | |
|     this.spaces = ''
 | |
| 
 | |
|     if (this.current.parent) {
 | |
|       this.current.source.end = this.getPosition(token[2])
 | |
|       this.current.source.end.offset++
 | |
|       this.current = this.current.parent
 | |
|     } else {
 | |
|       this.unexpectedClose(token)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   endFile() {
 | |
|     if (this.current.parent) this.unclosedBlock()
 | |
|     if (this.current.nodes && this.current.nodes.length) {
 | |
|       this.current.raws.semicolon = this.semicolon
 | |
|     }
 | |
|     this.current.raws.after = (this.current.raws.after || '') + this.spaces
 | |
|     this.root.source.end = this.getPosition(this.tokenizer.position())
 | |
|   }
 | |
| 
 | |
|   freeSemicolon(token) {
 | |
|     this.spaces += token[1]
 | |
|     if (this.current.nodes) {
 | |
|       let prev = this.current.nodes[this.current.nodes.length - 1]
 | |
|       if (prev && prev.type === 'rule' && !prev.raws.ownSemicolon) {
 | |
|         prev.raws.ownSemicolon = this.spaces
 | |
|         this.spaces = ''
 | |
|         prev.source.end = this.getPosition(token[2])
 | |
|         prev.source.end.offset += prev.raws.ownSemicolon.length
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // Helpers
 | |
| 
 | |
|   getPosition(offset) {
 | |
|     let pos = this.input.fromOffset(offset)
 | |
|     return {
 | |
|       column: pos.col,
 | |
|       line: pos.line,
 | |
|       offset
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   init(node, offset) {
 | |
|     this.current.push(node)
 | |
|     node.source = {
 | |
|       input: this.input,
 | |
|       start: this.getPosition(offset)
 | |
|     }
 | |
|     node.raws.before = this.spaces
 | |
|     this.spaces = ''
 | |
|     if (node.type !== 'comment') this.semicolon = false
 | |
|   }
 | |
| 
 | |
|   other(start) {
 | |
|     let end = false
 | |
|     let type = null
 | |
|     let colon = false
 | |
|     let bracket = null
 | |
|     let brackets = []
 | |
|     let customProperty = start[1].startsWith('--')
 | |
| 
 | |
|     let tokens = []
 | |
|     let token = start
 | |
|     while (token) {
 | |
|       type = token[0]
 | |
|       tokens.push(token)
 | |
| 
 | |
|       if (type === '(' || type === '[') {
 | |
|         if (!bracket) bracket = token
 | |
|         brackets.push(type === '(' ? ')' : ']')
 | |
|       } else if (customProperty && colon && type === '{') {
 | |
|         if (!bracket) bracket = token
 | |
|         brackets.push('}')
 | |
|       } else if (brackets.length === 0) {
 | |
|         if (type === ';') {
 | |
|           if (colon) {
 | |
|             this.decl(tokens, customProperty)
 | |
|             return
 | |
|           } else {
 | |
|             break
 | |
|           }
 | |
|         } else if (type === '{') {
 | |
|           this.rule(tokens)
 | |
|           return
 | |
|         } else if (type === '}') {
 | |
|           this.tokenizer.back(tokens.pop())
 | |
|           end = true
 | |
|           break
 | |
|         } else if (type === ':') {
 | |
|           colon = true
 | |
|         }
 | |
|       } else if (type === brackets[brackets.length - 1]) {
 | |
|         brackets.pop()
 | |
|         if (brackets.length === 0) bracket = null
 | |
|       }
 | |
| 
 | |
|       token = this.tokenizer.nextToken()
 | |
|     }
 | |
| 
 | |
|     if (this.tokenizer.endOfFile()) end = true
 | |
|     if (brackets.length > 0) this.unclosedBracket(bracket)
 | |
| 
 | |
|     if (end && colon) {
 | |
|       if (!customProperty) {
 | |
|         while (tokens.length) {
 | |
|           token = tokens[tokens.length - 1][0]
 | |
|           if (token !== 'space' && token !== 'comment') break
 | |
|           this.tokenizer.back(tokens.pop())
 | |
|         }
 | |
|       }
 | |
|       this.decl(tokens, customProperty)
 | |
|     } else {
 | |
|       this.unknownWord(tokens)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   parse() {
 | |
|     let token
 | |
|     while (!this.tokenizer.endOfFile()) {
 | |
|       token = this.tokenizer.nextToken()
 | |
| 
 | |
|       switch (token[0]) {
 | |
|         case 'space':
 | |
|           this.spaces += token[1]
 | |
|           break
 | |
| 
 | |
|         case ';':
 | |
|           this.freeSemicolon(token)
 | |
|           break
 | |
| 
 | |
|         case '}':
 | |
|           this.end(token)
 | |
|           break
 | |
| 
 | |
|         case 'comment':
 | |
|           this.comment(token)
 | |
|           break
 | |
| 
 | |
|         case 'at-word':
 | |
|           this.atrule(token)
 | |
|           break
 | |
| 
 | |
|         case '{':
 | |
|           this.emptyRule(token)
 | |
|           break
 | |
| 
 | |
|         default:
 | |
|           this.other(token)
 | |
|           break
 | |
|       }
 | |
|     }
 | |
|     this.endFile()
 | |
|   }
 | |
| 
 | |
|   precheckMissedSemicolon(/* tokens */) {
 | |
|     // Hook for Safe Parser
 | |
|   }
 | |
| 
 | |
|   raw(node, prop, tokens, customProperty) {
 | |
|     let token, type
 | |
|     let length = tokens.length
 | |
|     let value = ''
 | |
|     let clean = true
 | |
|     let next, prev
 | |
| 
 | |
|     for (let i = 0; i < length; i += 1) {
 | |
|       token = tokens[i]
 | |
|       type = token[0]
 | |
|       if (type === 'space' && i === length - 1 && !customProperty) {
 | |
|         clean = false
 | |
|       } else if (type === 'comment') {
 | |
|         prev = tokens[i - 1] ? tokens[i - 1][0] : 'empty'
 | |
|         next = tokens[i + 1] ? tokens[i + 1][0] : 'empty'
 | |
|         if (!SAFE_COMMENT_NEIGHBOR[prev] && !SAFE_COMMENT_NEIGHBOR[next]) {
 | |
|           if (value.slice(-1) === ',') {
 | |
|             clean = false
 | |
|           } else {
 | |
|             value += token[1]
 | |
|           }
 | |
|         } else {
 | |
|           clean = false
 | |
|         }
 | |
|       } else {
 | |
|         value += token[1]
 | |
|       }
 | |
|     }
 | |
|     if (!clean) {
 | |
|       let raw = tokens.reduce((all, i) => all + i[1], '')
 | |
|       node.raws[prop] = { raw, value }
 | |
|     }
 | |
|     node[prop] = value
 | |
|   }
 | |
| 
 | |
|   rule(tokens) {
 | |
|     tokens.pop()
 | |
| 
 | |
|     let node = new Rule()
 | |
|     this.init(node, tokens[0][2])
 | |
| 
 | |
|     node.raws.between = this.spacesAndCommentsFromEnd(tokens)
 | |
|     this.raw(node, 'selector', tokens)
 | |
|     this.current = node
 | |
|   }
 | |
| 
 | |
|   spacesAndCommentsFromEnd(tokens) {
 | |
|     let lastTokenType
 | |
|     let spaces = ''
 | |
|     while (tokens.length) {
 | |
|       lastTokenType = tokens[tokens.length - 1][0]
 | |
|       if (lastTokenType !== 'space' && lastTokenType !== 'comment') break
 | |
|       spaces = tokens.pop()[1] + spaces
 | |
|     }
 | |
|     return spaces
 | |
|   }
 | |
| 
 | |
|   // Errors
 | |
| 
 | |
|   spacesAndCommentsFromStart(tokens) {
 | |
|     let next
 | |
|     let spaces = ''
 | |
|     while (tokens.length) {
 | |
|       next = tokens[0][0]
 | |
|       if (next !== 'space' && next !== 'comment') break
 | |
|       spaces += tokens.shift()[1]
 | |
|     }
 | |
|     return spaces
 | |
|   }
 | |
| 
 | |
|   spacesFromEnd(tokens) {
 | |
|     let lastTokenType
 | |
|     let spaces = ''
 | |
|     while (tokens.length) {
 | |
|       lastTokenType = tokens[tokens.length - 1][0]
 | |
|       if (lastTokenType !== 'space') break
 | |
|       spaces = tokens.pop()[1] + spaces
 | |
|     }
 | |
|     return spaces
 | |
|   }
 | |
| 
 | |
|   stringFrom(tokens, from) {
 | |
|     let result = ''
 | |
|     for (let i = from; i < tokens.length; i++) {
 | |
|       result += tokens[i][1]
 | |
|     }
 | |
|     tokens.splice(from, tokens.length - from)
 | |
|     return result
 | |
|   }
 | |
| 
 | |
|   unclosedBlock() {
 | |
|     let pos = this.current.source.start
 | |
|     throw this.input.error('Unclosed block', pos.line, pos.column)
 | |
|   }
 | |
| 
 | |
|   unclosedBracket(bracket) {
 | |
|     throw this.input.error(
 | |
|       'Unclosed bracket',
 | |
|       { offset: bracket[2] },
 | |
|       { offset: bracket[2] + 1 }
 | |
|     )
 | |
|   }
 | |
| 
 | |
|   unexpectedClose(token) {
 | |
|     throw this.input.error(
 | |
|       'Unexpected }',
 | |
|       { offset: token[2] },
 | |
|       { offset: token[2] + 1 }
 | |
|     )
 | |
|   }
 | |
| 
 | |
|   unknownWord(tokens) {
 | |
|     throw this.input.error(
 | |
|       'Unknown word ' + tokens[0][1],
 | |
|       { offset: tokens[0][2] },
 | |
|       { offset: tokens[0][2] + tokens[0][1].length }
 | |
|     )
 | |
|   }
 | |
| 
 | |
|   unnamedAtrule(node, token) {
 | |
|     throw this.input.error(
 | |
|       'At-rule without name',
 | |
|       { offset: token[2] },
 | |
|       { offset: token[2] + token[1].length }
 | |
|     )
 | |
|   }
 | |
| }
 | |
| 
 | |
| module.exports = Parser
 |