var hasOwnProperty = Object.prototype.hasOwnProperty; var matchGraph = require('./match-graph'); var MATCH = matchGraph.MATCH; var MISMATCH = matchGraph.MISMATCH; var DISALLOW_EMPTY = matchGraph.DISALLOW_EMPTY; var TYPE = require('../tokenizer/const').TYPE; var STUB = 0; var TOKEN = 1; var OPEN_SYNTAX = 2; var CLOSE_SYNTAX = 3; var EXIT_REASON_MATCH = 'Match'; var EXIT_REASON_MISMATCH = 'Mismatch'; var EXIT_REASON_ITERATION_LIMIT = 'Maximum iteration number exceeded (please fill an issue on https://github.com/csstree/csstree/issues)'; var ITERATION_LIMIT = 10000; var totalIterationCount = 0; function mapList(list, fn) { var result = []; while (list) { result.unshift(fn(list)); list = list.prev; } return result; } function areStringsEqualCaseInsensitive(testStr, referenceStr) { if (testStr.length !== referenceStr.length) { return false; } for (var i = 0; i < testStr.length; i++) { var testCode = testStr.charCodeAt(i); var referenceCode = referenceStr.charCodeAt(i); // testCode.toLowerCase() for U+0041 LATIN CAPITAL LETTER A (A) .. U+005A LATIN CAPITAL LETTER Z (Z). if (testCode >= 0x0041 && testCode <= 0x005A) { testCode = testCode | 32; } if (testCode !== referenceCode) { return false; } } return true; } function isCommaContextStart(token) { if (token === null) { return true; } var ch = token.value.charAt(token.value.length - 1); return ( ch === ',' || ch === '(' || ch === '[' || ch === '/' ); } function isCommaContextEnd(token) { if (token === null) { return true; } var ch = token.value.charAt(0); return ( ch === ')' || ch === ']' || ch === '/' ); } function internalMatch(tokens, state, syntaxes) { function moveToNextToken() { do { tokenIndex++; token = tokenIndex < tokens.length ? tokens[tokenIndex] : null; } while (token !== null && !/\S/.test(token.value)); } function getNextToken(offset) { var nextIndex = tokenIndex + offset; return nextIndex < tokens.length ? tokens[nextIndex] : null; } function stateSnapshotFromSyntax(nextState, prev) { return { nextState: nextState, matchStack: matchStack, syntaxStack: syntaxStack, thenStack: thenStack, tokenIndex: tokenIndex, token: token, prev: prev }; } function pushThenStack(nextState) { thenStack = { nextState: nextState, matchStack: matchStack, syntaxStack: syntaxStack, prev: thenStack }; } function pushElseStack(nextState) { elseStack = stateSnapshotFromSyntax(nextState, elseStack); } function addTokenToMatch() { matchStack = { type: TOKEN, syntax: state.syntax, token: token, prev: matchStack }; moveToNextToken(); syntaxStash = null; if (tokenIndex > longestMatch) { longestMatch = tokenIndex; } } function openSyntax() { syntaxStack = { syntax: state, opts: state.syntax.opts || (syntaxStack !== null && syntaxStack.opts) || null, prev: syntaxStack }; matchStack = { type: OPEN_SYNTAX, syntax: state.syntax, token: matchStack.token, prev: matchStack }; } function closeSyntax() { if (matchStack.type === OPEN_SYNTAX) { matchStack = matchStack.prev; } else { matchStack = { type: CLOSE_SYNTAX, syntax: syntaxStack.syntax, token: matchStack.token, prev: matchStack }; } syntaxStack = syntaxStack.prev; } var syntaxStack = null; var thenStack = null; var elseStack = null; // null – stashing allowed, nothing stashed // false – stashing disabled, nothing stashed // anithing else – fail stashable syntaxes, some syntax stashed var syntaxStash = null; var iterationCount = 0; // count iterations and prevent infinite loop var exitReason = null; var token = null; var tokenIndex = -1; var longestMatch = 0; var matchStack = { type: STUB, syntax: null, token: null, prev: null }; moveToNextToken(); while (exitReason === null && ++iterationCount < ITERATION_LIMIT) { // console.log('--\n', // '#' + iterationCount, // require('util').inspect({ // match: mapList(matchStack, x => x.type === TOKEN ? x.token && x.token.value : x.syntax ? ({ [OPEN_SYNTAX]: '<', [CLOSE_SYNTAX]: ' x.id), // thenStack: mapList(thenStack, x => x.id), // token: token && token.value, // tokenCursor, // syntax: syntax.type + (syntax.id ? ' #' + syntax.id : '') // }, { depth: null }) // ); switch (state.type) { case 'Match': if (thenStack === null) { // turn to MISMATCH when some tokens left unmatched if (token !== null) { // doesn't mismatch if just one token left and it's an IE hack if (tokenIndex !== tokens.length - 1 || (token.value !== '\\0' && token.value !== '\\9')) { state = MISMATCH; break; } } // break the main loop, return a result - MATCH exitReason = EXIT_REASON_MATCH; break; } // go to next syntax (`then` branch) state = thenStack.nextState; // check match is not empty if (state === DISALLOW_EMPTY) { if (thenStack.matchStack.token === matchStack.token) { state = MISMATCH; break; } else { state = MATCH; } } // close syntax if needed while (syntaxStack !== null && thenStack.syntaxStack !== syntaxStack) { closeSyntax(); } // pop stack thenStack = thenStack.prev; break; case 'Mismatch': // when some syntax is stashed if (syntaxStash !== null && syntaxStash !== false) { // there is no else branches or a branch reduce match stack if (elseStack === null || tokenIndex > elseStack.tokenIndex) { // restore state from the stash elseStack = syntaxStash; syntaxStash = false; // disable stashing } } else if (elseStack === null) { // no else branches -> break the main loop // return a result - MISMATCH exitReason = EXIT_REASON_MISMATCH; break; } // go to next syntax (`else` branch) state = elseStack.nextState; // restore all the rest stack states thenStack = elseStack.thenStack; syntaxStack = elseStack.syntaxStack; matchStack = elseStack.matchStack; tokenIndex = elseStack.tokenIndex; token = elseStack.token; // pop stack elseStack = elseStack.prev; break; case 'MatchGraph': state = state.match; break; case 'If': // IMPORTANT: else stack push must go first, // since it stores the state of thenStack before changes if (state.else !== MISMATCH) { pushElseStack(state.else); } if (state.then !== MATCH) { pushThenStack(state.then); } state = state.match; break; case 'MatchOnce': state = { type: 'MatchOnceBuffer', syntax: state, index: 0, mask: 0 }; break; case 'MatchOnceBuffer': var terms = state.syntax.terms; if (state.index === terms.length) { // no matches at all or it's required all terms to be matched if (state.mask === 0 || state.syntax.all) { state = MISMATCH; break; } // a partial match is ok state = MATCH; break; } // all terms are matched if (state.mask === (1 << terms.length) - 1) { state = MATCH; break; } for (; state.index < terms.length; state.index++) { var matchFlag = 1 << state.index; if ((state.mask & matchFlag) === 0) { // IMPORTANT: else stack push must go first, // since it stores the state of thenStack before changes pushElseStack(state); pushThenStack({ type: 'AddMatchOnce', syntax: state.syntax, mask: state.mask | matchFlag }); // match state = terms[state.index++]; break; } } break; case 'AddMatchOnce': state = { type: 'MatchOnceBuffer', syntax: state.syntax, index: 0, mask: state.mask }; break; case 'Enum': if (token !== null) { var name = token.value.toLowerCase(); // drop \0 and \9 hack from keyword name if (name.indexOf('\\') !== -1) { name = name.replace(/\\[09].*$/, ''); } if (hasOwnProperty.call(state.map, name)) { state = state.map[name]; break; } } state = MISMATCH; break; case 'Generic': var opts = syntaxStack !== null ? syntaxStack.opts : null; var tokenCount = Math.floor(state.fn(token, getNextToken, opts)); if (!isNaN(tokenCount) && tokenCount > 0) { for (var lastTokenIndex = tokenIndex + tokenCount; tokenIndex < lastTokenIndex;) { addTokenToMatch(); } state = MATCH; } else { state = MISMATCH; } break; case 'Type': case 'Property': var syntaxDict = state.type === 'Type' ? 'types' : 'properties'; var dictSyntax = hasOwnProperty.call(syntaxes, syntaxDict) ? syntaxes[syntaxDict][state.name] : null; if (!dictSyntax || !dictSyntax.match) { throw new Error( 'Bad syntax reference: ' + (state.type === 'Type' ? '<' + state.name + '>' : '<\'' + state.name + '\'>') ); } // stash a syntax for types with low priority if (syntaxStash !== false && token !== null && state.type === 'Type') { var lowPriorityMatching = // https://drafts.csswg.org/css-values-4/#custom-idents // When parsing positionally-ambiguous keywords in a property value, a production // can only claim the keyword if no other unfulfilled production can claim it. (state.name === 'custom-ident' && token.type === TYPE.Ident) || // https://drafts.csswg.org/css-values-4/#lengths // ... if a `0` could be parsed as either a or a in a property (such as line-height), // it must parse as a (state.name === 'length' && token.value === '0'); if (lowPriorityMatching) { if (syntaxStash === null) { syntaxStash = stateSnapshotFromSyntax(state, elseStack); } state = MISMATCH; break; } } openSyntax(); state = dictSyntax.match; break; case 'Keyword': var name = state.name; if (token !== null) { var keywordName = token.value; // drop \0 and \9 hack from keyword name if (keywordName.indexOf('\\') !== -1) { keywordName = keywordName.replace(/\\[09].*$/, ''); } if (areStringsEqualCaseInsensitive(keywordName, name)) { addTokenToMatch(); state = MATCH; break; } } state = MISMATCH; break; case 'AtKeyword': case 'Function': if (token !== null && areStringsEqualCaseInsensitive(token.value, state.name)) { addTokenToMatch(); state = MATCH; break; } state = MISMATCH; break; case 'Token': if (token !== null && token.value === state.value) { addTokenToMatch(); state = MATCH; break; } state = MISMATCH; break; case 'Comma': if (token !== null && token.value === ',') { if (isCommaContextStart(matchStack.token)) { state = MISMATCH; } else { addTokenToMatch(); state = isCommaContextEnd(token) ? MISMATCH : MATCH; } } else { state = isCommaContextStart(matchStack.token) || isCommaContextEnd(token) ? MATCH : MISMATCH; } break; case 'String': var string = ''; for (var lastTokenIndex = tokenIndex; lastTokenIndex < tokens.length && string.length < state.value.length; lastTokenIndex++) { string += tokens[lastTokenIndex].value; } if (areStringsEqualCaseInsensitive(string, state.value)) { for (; tokenIndex < lastTokenIndex;) { addTokenToMatch(); } state = MATCH; } else { state = MISMATCH; } break; default: throw new Error('Unknown node type: ' + state.type); } } totalIterationCount += iterationCount; if (exitReason === null) { console.warn('[csstree-match] BREAK after ' + ITERATION_LIMIT + ' iterations'); exitReason = EXIT_REASON_ITERATION_LIMIT; } if (exitReason === EXIT_REASON_MATCH) { while (syntaxStack !== null) { closeSyntax(); } } else { matchStack = null; } return { tokens: tokens, reason: exitReason, iterations: iterationCount, match: matchStack, longestMatch: longestMatch }; } function matchAsList(tokens, matchGraph, syntaxes) { var matchResult = internalMatch(tokens, matchGraph, syntaxes || {}); if (matchResult.match !== null) { matchResult.match = mapList(matchResult.match, function(item) { if (item.type === OPEN_SYNTAX || item.type === CLOSE_SYNTAX) { return { type: item.type, syntax: item.syntax }; } return { syntax: item.syntax, token: item.token && item.token.value, node: item.token && item.token.node }; }).slice(1); } return matchResult; } function matchAsTree(tokens, matchGraph, syntaxes) { var matchResult = internalMatch(tokens, matchGraph, syntaxes || {}); if (matchResult.match === null) { return matchResult; } var cursor = matchResult.match; var host = matchResult.match = { syntax: matchGraph.syntax || null, match: [] }; var stack = [host]; // revert a list var prev = null; var next = null; while (cursor !== null) { next = cursor.prev; cursor.prev = prev; prev = cursor; cursor = next; } // init the cursor to start with 2nd item since 1st is a stub item cursor = prev.prev; // build a tree while (cursor !== null && cursor.syntax !== null) { var entry = cursor; switch (entry.type) { case OPEN_SYNTAX: host.match.push(host = { syntax: entry.syntax, match: [] }); stack.push(host); break; case CLOSE_SYNTAX: stack.pop(); host = stack[stack.length - 1]; break; default: host.match.push({ syntax: entry.syntax || null, token: entry.token.value, node: entry.token.node }); } cursor = cursor.prev; } return matchResult; } module.exports = { matchAsList: matchAsList, matchAsTree: matchAsTree, getTotalIterationCount: function() { return totalIterationCount; } };