| 'use strict'; |
| |
| var names = require('../utils/names'); |
| var MULTIPLIER_DEFAULT = { |
| comma: false, |
| min: 1, |
| max: 1 |
| }; |
| |
| function skipSpaces(node) { |
| while (node !== null && (node.data.type === 'WhiteSpace' || node.data.type === 'Comment')) { |
| node = node.next; |
| } |
| |
| return node; |
| } |
| |
| function putResult(buffer, match) { |
| var type = match.type || match.syntax.type; |
| |
| // ignore groups |
| if (type === 'Group') { |
| buffer.push.apply(buffer, match.match); |
| } else { |
| buffer.push(match); |
| } |
| } |
| |
| function matchToJSON() { |
| return { |
| type: this.syntax.type, |
| name: this.syntax.name, |
| match: this.match, |
| node: this.node |
| }; |
| } |
| |
| function buildMatchNode(badNode, lastNode, next, match) { |
| if (badNode) { |
| return { |
| badNode: badNode, |
| lastNode: null, |
| next: null, |
| match: null |
| }; |
| } |
| |
| return { |
| badNode: null, |
| lastNode: lastNode, |
| next: next, |
| match: match |
| }; |
| } |
| |
| function matchGroup(lexer, syntaxNode, node) { |
| var result = []; |
| var buffer; |
| var multiplier = syntaxNode.multiplier || MULTIPLIER_DEFAULT; |
| var min = multiplier.min; |
| var max = multiplier.max === 0 ? Infinity : multiplier.max; |
| var lastCommaTermCount; |
| var lastComma; |
| var matchCount = 0; |
| var lastNode = null; |
| var badNode = null; |
| |
| mismatch: |
| while (matchCount < max) { |
| node = skipSpaces(node); |
| buffer = []; |
| |
| switch (syntaxNode.combinator) { |
| case '|': |
| for (var i = 0; i < syntaxNode.terms.length; i++) { |
| var term = syntaxNode.terms[i]; |
| var res = matchSyntax(lexer, term, node); |
| |
| if (res.match) { |
| putResult(buffer, res.match); |
| node = res.next; |
| break; // continue matching |
| } else if (res.badNode) { |
| badNode = res.badNode; |
| break mismatch; |
| } else if (res.lastNode) { |
| lastNode = res.lastNode; |
| } |
| } |
| |
| if (buffer.length === 0) { |
| break mismatch; // nothing found -> stop matching |
| } |
| |
| break; |
| |
| case ' ': |
| var beforeMatchNode = node; |
| var lastMatchedTerm = null; |
| var hasTailMatch = false; |
| var commaMissed = false; |
| |
| for (var i = 0; i < syntaxNode.terms.length; i++) { |
| var term = syntaxNode.terms[i]; |
| var res = matchSyntax(lexer, term, node); |
| |
| if (res.match) { |
| if (term.type === 'Comma' && i !== 0 && !hasTailMatch) { |
| // recover cursor to state before last match and stop matching |
| lastNode = node && node.data; |
| node = beforeMatchNode; |
| break mismatch; |
| } |
| |
| // non-empty match (res.next will refer to another node) |
| if (res.next !== node) { |
| // match should be preceded by a comma |
| if (commaMissed) { |
| lastNode = node && node.data; |
| node = beforeMatchNode; |
| break mismatch; |
| } |
| |
| hasTailMatch = term.type !== 'Comma'; |
| lastMatchedTerm = term; |
| } |
| |
| putResult(buffer, res.match); |
| node = skipSpaces(res.next); |
| } else if (res.badNode) { |
| badNode = res.badNode; |
| break mismatch; |
| } else { |
| if (res.lastNode) { |
| lastNode = res.lastNode; |
| } |
| |
| // it's ok when comma doesn't match when no matches yet |
| // but only if comma is not first or last term |
| if (term.type === 'Comma' && i !== 0 && i !== syntaxNode.terms.length - 1) { |
| if (hasTailMatch) { |
| commaMissed = true; |
| } |
| continue; |
| } |
| |
| // recover cursor to state before last match and stop matching |
| lastNode = res.lastNode || (node && node.data); |
| node = beforeMatchNode; |
| break mismatch; |
| } |
| } |
| |
| // don't allow empty match when [ ]! |
| if (!lastMatchedTerm && syntaxNode.disallowEmpty) { |
| // empty match but shouldn't |
| // recover cursor to state before last match and stop matching |
| lastNode = node && node.data; |
| node = beforeMatchNode; |
| break mismatch; |
| } |
| |
| // don't allow comma at the end but only if last term isn't a comma |
| if (lastMatchedTerm && lastMatchedTerm.type === 'Comma' && term.type !== 'Comma') { |
| lastNode = node && node.data; |
| node = beforeMatchNode; |
| break mismatch; |
| } |
| |
| break; |
| |
| case '&&': |
| var beforeMatchNode = node; |
| var lastMatchedTerm = null; |
| var terms = syntaxNode.terms.slice(); |
| |
| while (terms.length) { |
| var wasMatch = false; |
| var emptyMatched = 0; |
| |
| for (var i = 0; i < terms.length; i++) { |
| var term = terms[i]; |
| var res = matchSyntax(lexer, term, node); |
| |
| if (res.match) { |
| // non-empty match (res.next will refer to another node) |
| if (res.next !== node) { |
| lastMatchedTerm = term; |
| } else { |
| emptyMatched++; |
| continue; |
| } |
| |
| wasMatch = true; |
| terms.splice(i--, 1); |
| putResult(buffer, res.match); |
| node = skipSpaces(res.next); |
| break; |
| } else if (res.badNode) { |
| badNode = res.badNode; |
| break mismatch; |
| } else if (res.lastNode) { |
| lastNode = res.lastNode; |
| } |
| } |
| |
| if (!wasMatch) { |
| // terms left, but they all are optional |
| if (emptyMatched === terms.length) { |
| break; |
| } |
| |
| // not ok |
| lastNode = node && node.data; |
| node = beforeMatchNode; |
| break mismatch; |
| } |
| } |
| |
| if (!lastMatchedTerm && syntaxNode.disallowEmpty) { // don't allow empty match when [ ]! |
| // empty match but shouldn't |
| // recover cursor to state before last match and stop matching |
| lastNode = node && node.data; |
| node = beforeMatchNode; |
| break mismatch; |
| } |
| |
| break; |
| |
| case '||': |
| var beforeMatchNode = node; |
| var lastMatchedTerm = null; |
| var terms = syntaxNode.terms.slice(); |
| |
| while (terms.length) { |
| var wasMatch = false; |
| var emptyMatched = 0; |
| |
| for (var i = 0; i < terms.length; i++) { |
| var term = terms[i]; |
| var res = matchSyntax(lexer, term, node); |
| |
| if (res.match) { |
| // non-empty match (res.next will refer to another node) |
| if (res.next !== node) { |
| lastMatchedTerm = term; |
| } else { |
| emptyMatched++; |
| continue; |
| } |
| |
| wasMatch = true; |
| terms.splice(i--, 1); |
| putResult(buffer, res.match); |
| node = skipSpaces(res.next); |
| break; |
| } else if (res.badNode) { |
| badNode = res.badNode; |
| break mismatch; |
| } else if (res.lastNode) { |
| lastNode = res.lastNode; |
| } |
| } |
| |
| if (!wasMatch) { |
| break; |
| } |
| } |
| |
| // don't allow empty match |
| if (!lastMatchedTerm && (emptyMatched !== terms.length || syntaxNode.disallowEmpty)) { |
| // empty match but shouldn't |
| // recover cursor to state before last match and stop matching |
| lastNode = node && node.data; |
| node = beforeMatchNode; |
| break mismatch; |
| } |
| |
| break; |
| } |
| |
| // flush buffer |
| result.push.apply(result, buffer); |
| matchCount++; |
| |
| if (!node) { |
| break; |
| } |
| |
| if (multiplier.comma) { |
| if (lastComma && lastCommaTermCount === result.length) { |
| // nothing match after comma |
| break mismatch; |
| } |
| |
| node = skipSpaces(node); |
| if (node !== null && node.data.type === 'Operator' && node.data.value === ',') { |
| result.push({ |
| syntax: syntaxNode, |
| match: [{ |
| type: 'ASTNode', |
| node: node.data, |
| childrenMatch: null |
| }] |
| }); |
| lastCommaTermCount = result.length; |
| lastComma = node; |
| node = node.next; |
| } else { |
| lastNode = node !== null ? node.data : null; |
| break mismatch; |
| } |
| } |
| } |
| |
| // console.log(syntaxNode.type, badNode, lastNode); |
| |
| if (lastComma && lastCommaTermCount === result.length) { |
| // nothing match after comma |
| node = lastComma; |
| result.pop(); |
| } |
| |
| return buildMatchNode(badNode, lastNode, node, matchCount < min ? null : { |
| syntax: syntaxNode, |
| match: result, |
| toJSON: matchToJSON |
| }); |
| } |
| |
| function matchSyntax(lexer, syntaxNode, node) { |
| var badNode = null; |
| var lastNode = null; |
| var match = null; |
| |
| switch (syntaxNode.type) { |
| case 'Group': |
| return matchGroup(lexer, syntaxNode, node); |
| |
| case 'Function': |
| // expect a function node |
| if (!node || node.data.type !== 'Function') { |
| break; |
| } |
| |
| var keyword = names.keyword(node.data.name); |
| var name = syntaxNode.name.toLowerCase(); |
| |
| // check function name with vendor consideration |
| if (name !== keyword.name) { |
| break; |
| } |
| |
| var res = matchSyntax(lexer, syntaxNode.children, node.data.children.head); |
| if (!res.match || res.next) { |
| badNode = res.badNode || res.lastNode || (res.next ? res.next.data : null) || node.data; |
| break; |
| } |
| |
| match = [{ |
| type: 'ASTNode', |
| node: node.data, |
| childrenMatch: res.match.match |
| }]; |
| |
| // Use node.next instead of res.next here since syntax is matching |
| // for internal list and it should be completelly matched (res.next is null at this point). |
| // Therefore function is matched and we are going to next node |
| node = node.next; |
| break; |
| |
| case 'Parentheses': |
| if (!node || node.data.type !== 'Parentheses') { |
| break; |
| } |
| |
| var res = matchSyntax(lexer, syntaxNode.children, node.data.children.head); |
| if (!res.match || res.next) { |
| badNode = res.badNode || res.lastNode || (res.next ? res.next.data : null) || node.data; // TODO: case when res.next === null |
| break; |
| } |
| |
| match = [{ |
| type: 'ASTNode', |
| node: node.data, |
| childrenMatch: res.match.match |
| }]; |
| |
| node = res.next; |
| break; |
| |
| case 'Type': |
| var typeSyntax = lexer.getType(syntaxNode.name); |
| if (!typeSyntax) { |
| throw new Error('Unknown syntax type `' + syntaxNode.name + '`'); |
| } |
| |
| var res = typeSyntax.match(node); |
| if (!res.match) { |
| badNode = res && res.badNode; // TODO: case when res.next === null |
| lastNode = (res && res.lastNode) || (node && node.data); |
| break; |
| } |
| |
| node = res.next; |
| putResult(match = [], res.match); |
| if (match.length === 0) { |
| match = null; |
| } |
| break; |
| |
| case 'Property': |
| var propertySyntax = lexer.getProperty(syntaxNode.name); |
| if (!propertySyntax) { |
| throw new Error('Unknown property `' + syntaxNode.name + '`'); |
| } |
| |
| var res = propertySyntax.match(node); |
| if (!res.match) { |
| badNode = res && res.badNode; // TODO: case when res.next === null |
| lastNode = (res && res.lastNode) || (node && node.data); |
| break; |
| } |
| |
| node = res.next; |
| putResult(match = [], res.match); |
| if (match.length === 0) { |
| match = null; |
| } |
| break; |
| |
| case 'Keyword': |
| if (!node) { |
| break; |
| } |
| |
| if (node.data.type === 'Identifier') { |
| var keyword = names.keyword(node.data.name); |
| var keywordName = keyword.name; |
| var name = syntaxNode.name.toLowerCase(); |
| |
| // drop \0 and \9 hack from keyword name |
| if (keywordName.indexOf('\\') !== -1) { |
| keywordName = keywordName.replace(/\\[09].*$/, ''); |
| } |
| |
| if (name !== keywordName) { |
| break; |
| } |
| } else { |
| // keyword may to be a number (e.g. font-weight: 400 ) |
| if (node.data.type !== 'Number' || node.data.value !== syntaxNode.name) { |
| break; |
| } |
| } |
| |
| match = [{ |
| type: 'ASTNode', |
| node: node.data, |
| childrenMatch: null |
| }]; |
| node = node.next; |
| break; |
| |
| case 'Slash': |
| case 'Comma': |
| if (!node || node.data.type !== 'Operator' || node.data.value !== syntaxNode.value) { |
| break; |
| } |
| |
| match = [{ |
| type: 'ASTNode', |
| node: node.data, |
| childrenMatch: null |
| }]; |
| node = node.next; |
| break; |
| |
| case 'String': |
| if (!node || node.data.type !== 'String') { |
| break; |
| } |
| |
| match = [{ |
| type: 'ASTNode', |
| node: node.data, |
| childrenMatch: null |
| }]; |
| node = node.next; |
| break; |
| |
| case 'ASTNode': |
| if (node && syntaxNode.match(node)) { |
| match = { |
| type: 'ASTNode', |
| node: node.data, |
| childrenMatch: null |
| }; |
| node = node.next; |
| } |
| return buildMatchNode(badNode, lastNode, node, match); |
| |
| default: |
| throw new Error('Not implemented yet node type: ' + syntaxNode.type); |
| } |
| |
| return buildMatchNode(badNode, lastNode, node, match === null ? null : { |
| syntax: syntaxNode, |
| match: match, |
| toJSON: matchToJSON |
| }); |
| |
| }; |
| |
| module.exports = matchSyntax; |