diff --git a/packages/less/package-lock.json b/packages/less/package-lock.json index 26bb94d201..21ff5f4ffa 100644 --- a/packages/less/package-lock.json +++ b/packages/less/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", - "tslib": "^2.3.0" + "tslib": "^2.6.2" }, "bin": { "lessc": "bin/lessc" @@ -55,7 +55,7 @@ "resolve": "^1.17.0", "rollup": "^2.52.2", "rollup-plugin-terser": "^5.1.1", - "rollup-plugin-typescript2": "^0.29.0", + "rollup-plugin-typescript2": "^0.36.0", "semver": "^6.3.0", "shx": "^0.3.2", "time-grunt": "^1.3.0", @@ -6648,39 +6648,84 @@ } }, "node_modules/rollup-plugin-typescript2": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.29.0.tgz", - "integrity": "sha512-YytahBSZCIjn/elFugEGQR5qTsVhxhUwGZIsA9TmrSsC88qroGo65O5HZP/TTArH2dm0vUmYWhKchhwi2wL9bw==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.36.0.tgz", + "integrity": "sha512-NB2CSQDxSe9+Oe2ahZbf+B4bh7pHwjV5L+RSYpCu7Q5ROuN94F9b6ioWwKfz3ueL3KTtmX4o2MUH2cgHDIEUsw==", "dev": true, "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "find-cache-dir": "^3.3.1", - "fs-extra": "8.1.0", - "resolve": "1.17.0", - "tslib": "2.0.1" + "@rollup/pluginutils": "^4.1.2", + "find-cache-dir": "^3.3.2", + "fs-extra": "^10.0.0", + "semver": "^7.5.4", + "tslib": "^2.6.2" }, "peerDependencies": { "rollup": ">=1.26.3", "typescript": ">=2.4.0" } }, - "node_modules/rollup-plugin-typescript2/node_modules/resolve": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", - "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "node_modules/rollup-plugin-typescript2/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", "dev": true, "dependencies": { - "path-parse": "^1.0.6" + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/rollup-plugin-typescript2/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/rollup-plugin-typescript2/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/rollup-plugin-typescript2/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/rollup-plugin-typescript2/node_modules/tslib": { + "node_modules/rollup-plugin-typescript2/node_modules/universalify": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", - "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==", - "dev": true + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } }, "node_modules/rollup-pluginutils": { "version": "2.8.2", @@ -13388,31 +13433,62 @@ } }, "rollup-plugin-typescript2": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.29.0.tgz", - "integrity": "sha512-YytahBSZCIjn/elFugEGQR5qTsVhxhUwGZIsA9TmrSsC88qroGo65O5HZP/TTArH2dm0vUmYWhKchhwi2wL9bw==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.36.0.tgz", + "integrity": "sha512-NB2CSQDxSe9+Oe2ahZbf+B4bh7pHwjV5L+RSYpCu7Q5ROuN94F9b6ioWwKfz3ueL3KTtmX4o2MUH2cgHDIEUsw==", "dev": true, "requires": { - "@rollup/pluginutils": "^3.1.0", - "find-cache-dir": "^3.3.1", - "fs-extra": "8.1.0", - "resolve": "1.17.0", - "tslib": "2.0.1" + "@rollup/pluginutils": "^4.1.2", + "find-cache-dir": "^3.3.2", + "fs-extra": "^10.0.0", + "semver": "^7.5.4", + "tslib": "^2.6.2" }, "dependencies": { - "resolve": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", - "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", "dev": true, "requires": { - "path-parse": "^1.0.6" + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" } }, - "tslib": { + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "universalify": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", - "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true } } diff --git a/packages/less/package.json b/packages/less/package.json index 2606f4e9cb..9d9cbf5a1a 100644 --- a/packages/less/package.json +++ b/packages/less/package.json @@ -96,7 +96,7 @@ "resolve": "^1.17.0", "rollup": "^2.52.2", "rollup-plugin-terser": "^5.1.1", - "rollup-plugin-typescript2": "^0.29.0", + "rollup-plugin-typescript2": "^0.36.0", "semver": "^6.3.0", "shx": "^0.3.2", "time-grunt": "^1.3.0", @@ -134,7 +134,7 @@ "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", - "tslib": "^2.3.0" + "tslib": "^2.6.2" }, "gitHead": "1df9072ee9ebdadc791bf35dfb1dbc3ef9f1948f" } diff --git a/packages/less/src/less/parser/parser-input.js b/packages/less/src/less/parser/parser-input.js index a96338a510..92389d25a5 100644 --- a/packages/less/src/less/parser/parser-input.js +++ b/packages/less/src/less/parser/parser-input.js @@ -1,31 +1,64 @@ import chunker from './chunker'; export default () => { - let // Less input string - input; + // Less input string + let input; - let // current chunk - j; + // current chunk + let j; - const // holds state for backtracking - saveStack = []; + // holds state for backtracking + const saveStack = []; - let // furthest index the parser has gone to - furthest; + // furthest index the parser has gone to + let furthest; - let // if this is furthest we got to, this is the probably cause - furthestPossibleErrorMessage; + // if this is furthest we got to, this is the probably cause + let furthestPossibleErrorMessage; - let // chunkified input - chunks; + // chunkified input + let chunks; - let // current chunk - current; + // current chunk + let current; - let // index of current chunk, in `input` - currentPos; + // index of current chunk, in `input` + let currentPos; + /** + * @type {{ + * i: number + * save(): void + * restore(possibleErrorMessage?: string): void + * forget(): void + * isWhitespace(offset?: number): boolean + * $re(tok: RegExp): string | null + * $char(tok: string): string | null + * $peekChar(tok: string): string | null + * $str(tok: string): string | null + * $quoted(loc?: number): string | [string, string] + * $parseUntil(tok: string | RegExp): string | string[] | null + * autoCommentAbsorb: boolean + * commentStore: {index: number, text: string, isLineComment: boolean}[] + * finished: boolean + * peek(tok: string | RegExp): boolean + * peekChar(tok: string): boolean + * currentChar(): string + * prevChar(): string + * getInput(): string + * peekNotNumeric(): boolean + * start(str: string, chunkInput: boolean, failFunction: (message: string, index?: number) => void): void + * end(): { + * isFinished: boolean + * furthest: number + * furthestPossibleErrorMessage: string + * furthestReachedEnd: boolean + * furthestChar: string + * } + * }} + */ const parserInput = {}; + const CHARCODE_SPACE = 32; const CHARCODE_TAB = 9; const CHARCODE_LF = 10; diff --git a/packages/less/src/less/parser/parser.js b/packages/less/src/less/parser/parser.js index 59aa2eef42..3554c9de64 100644 --- a/packages/less/src/less/parser/parser.js +++ b/packages/less/src/less/parser/parser.js @@ -1,8 +1,9 @@ +// @ts-check import LessError from '../less-error'; -import tree from '../tree'; import visitors from '../visitors'; import getParserInput from './parser-input'; import * as utils from '../utils'; +import tree from '../tree' import functionRegistry from '../functions/function-registry'; import { ContainerSyntaxOptions, MediaSyntaxOptions } from '../tree/atrule-syntax'; @@ -38,2446 +39,2741 @@ import { ContainerSyntaxOptions, MediaSyntaxOptions } from '../tree/atrule-synta // a terminal string or regexp, or a non-terminal function to call. // It also takes care of moving all the indices forwards. // - -const Parser = function Parser(context, imports, fileInfo, currentIndex) { +/** + * + * @param {*} context + * @param {*} imports + * @param {*} fileInfo + * @param {number} currentIndex + */ +let Parser = function Parser(context, imports, fileInfo, currentIndex) { currentIndex = currentIndex || 0; - let parsers; - const parserInput = getParserInput(); - - function error(msg, type) { - throw new LessError( - { - index: parserInput.i, - filename: fileInfo.filename, - type: type || 'Syntax', - message: msg - }, - imports - ); - } - function expect(arg, msg) { - // some older browsers return typeof 'function' for RegExp - const result = (arg instanceof Function) ? arg.call(parsers) : parserInput.$re(arg); - if (result) { - return result; + /** + * Tree nodes - destructure during call to avoid circularity. + */ + const { + Node, + Color, + AtRule, + DetachedRuleset, + Operation, + Dimension, + Keyword, + Variable, + Property, + Ruleset, + Element, + Attribute, + Combinator, + Selector, + Quoted, + Expression, + Declaration, + Call, + URL, + Import, + Comment, + Anonymous, + Value, + JavaScript, + Assignment, + Condition, + QueryInParens, + Paren, + Media, + Container, + UnicodeDescriptor, + Negative, + Extend, + VariableCall, + NamespaceValue, + mixin: { + Call: MixinCall, + Definition: MixinDefinition } + } = tree - error(msg || (typeof arg === 'string' - ? `expected '${arg}' got '${parserInput.currentChar()}'` - : 'unexpected token')); - } + let parsers = (() => { + // + // The `primary` rule is the *entry* and *exit* point of the parser. + // The rules here can appear at any level of the parse tree. + // + // The recursive nature of the grammar is an interplay between the `block` + // rule, which represents `{ ... }`, the `ruleset` rule, and this `primary` rule, + // as represented by this simplified grammar: + // + // primary → (ruleset | declaration)+ + // ruleset → selector+ block + // block → '{' primary '}' + // + // Only at one point is the primary rule not called from the + // block rule: at the root level. + // + let parsePrimary = () => { + /** @type {Array} */ + let root = []; + let node; - // Specialization of expect() - function expectChar(arg, msg) { - if (parserInput.$char(arg)) { - return arg; - } - error(msg || `expected '${arg}' got '${parserInput.currentChar()}'`); - } + while (true) { + while (true) { + node = parseComment(); + if (!node) { break; } + root.push(node); + } + // always process comments before deciding if finished + if (parserInput.finished) { + break; + } + if (parserInput.peek('}')) { + break; + } - function getDebugInfo(index) { - const filename = fileInfo.filename; + node = parseExtendRule(); + if (node) { + root = root.concat(node); + continue; + } - return { - lineNumber: utils.getLocation(index, parserInput.getInput()).line + 1, - fileName: filename - }; - } + node = mDefinition() + || parseDeclaration() + || mCall(false, false) + || parseRuleset() + || parseVariableCall() + || pCall() + || parseAtrule(); - /** - * Used after initial parsing to create nodes on the fly - * - * @param {String} str - string to parse - * @param {Array} parseList - array of parsers to run input through e.g. ["value", "important"] - * @param {Number} currentIndex - start number to begin indexing - * @param {Object} fileInfo - fileInfo to attach to created nodes - */ - function parseNode(str, parseList, callback) { - let result; - const returnNodes = []; - const parser = parserInput; + if (node) { + root.push(node); + } else { + let foundSemiColon = false; + while (parserInput.$char(';')) { + foundSemiColon = true; + } + if (!foundSemiColon) { + break; + } + } + } - try { - parser.start(str, false, function fail(msg, index) { - callback({ - message: msg, - index: index + currentIndex - }); - }); - for (let x = 0, p; (p = parseList[x]); x++) { - result = parsers[p](); - returnNodes.push(result || null); + return root; + } + + // comments are collected by the main parsing mechanism and then assigned to nodes + // where the current structure allows it + let parseComment = () => { + if (parserInput.commentStore.length) { + /** @type {*} */ + const comment = parserInput.commentStore.shift(); + return new(Comment)(comment.text, comment.isLineComment, comment.index + currentIndex, fileInfo); } + } - const endInfo = parser.end(); - if (endInfo.isFinished) { - callback(null, returnNodes); + // + // Entities are tokens which can be found inside an Expression + // Entity parsing functions prefaced by `p` + // + let pMixinLookup = () => { + return parsers.mixin.call(true, true); + } + /** + * A string, which supports escaping " and ' + * + * "milky way" 'he\'s the one!' + * + * @param {boolean} [forceEscaped] + */ + let pQuoted = (forceEscaped) => { + /** @type {string} */ + let str; + const index = parserInput.i; + let isEscaped = false; + + parserInput.save(); + if (parserInput.$char('~')) { + isEscaped = true; + } else if (forceEscaped) { + parserInput.restore(); + return; } - else { - callback(true, null); + + str = /** @type {string} */ (parserInput.$quoted()); + if (!str) { + parserInput.restore(); + return; } - } catch (e) { - throw new LessError({ - index: e.index + currentIndex, - message: e.message - }, imports, fileInfo.filename); + parserInput.forget(); + + return new(Quoted)(str.charAt(0), str.substr(1, str.length - 2), isEscaped, index + currentIndex, fileInfo); } - } - // - // The Parser - // - return { - parserInput, - imports, - fileInfo, - parseNode, // - // Parse an input string into an abstract syntax tree, - // @param str A string containing 'less' markup - // @param callback call `callback` when done. - // @param [additionalData] An optional map which can contains vars - a map (key, value) of variables to apply + // A catch-all word, such as: // - parse: function (str, callback, additionalData) { - let root; - let err = null; - let globalVars; - let modifyVars; - let ignored; - let preText = ''; + // black border-collapse + // + let pKeyword = () => { + const k = parserInput.$char('%') || parserInput.$re(/^\[?(?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+\]?/); + if (k) { + return Color.fromKeyword(k) || new(Keyword)(k); + } + } - // Optionally disable @plugin parsing - if (additionalData && additionalData.disablePluginRule) { - parsers.plugin = function() { - var dir = parserInput.$re(/^@plugin?\s+/); - if (dir) { - error('@plugin statements are not allowed when disablePluginRule is set to true'); - } - } + // + // A function call + // + // rgb(255, 0, 255) + // + // The arguments are parsed with the `entities.arguments` parser. + // + let pCall = () => { + let name; + let args; + let func; + const index = parserInput.i; + + // http://jsperf.com/case-insensitive-regex-vs-strtolower-then-regex/18 + if (parserInput.peek(/^url\(/i)) { + return; } - globalVars = (additionalData && additionalData.globalVars) ? `${Parser.serializeVars(additionalData.globalVars)}\n` : ''; - modifyVars = (additionalData && additionalData.modifyVars) ? `\n${Parser.serializeVars(additionalData.modifyVars)}` : ''; + parserInput.save(); - if (context.pluginManager) { - const preProcessors = context.pluginManager.getPreProcessors(); - for (let i = 0; i < preProcessors.length; i++) { - str = preProcessors[i].process(str, { context, imports, fileInfo }); + name = parserInput.$re(/^([\w-]+|%|~|progid:[\w.]+)\(/); + if (!name) { + parserInput.forget(); + return; + } + + name = name[1]; + func = pCustomFuncCall(name); + if (func) { + args = func.parse(); + if (args && func.stop) { + parserInput.forget(); + return args; } } - if (globalVars || (additionalData && additionalData.banner)) { - preText = ((additionalData && additionalData.banner) ? additionalData.banner : '') + globalVars; - ignored = imports.contentsIgnoredChars; - ignored[fileInfo.filename] = ignored[fileInfo.filename] || 0; - ignored[fileInfo.filename] += preText.length; + args = pArguments(args); + + if (!parserInput.$char(')')) { + parserInput.restore('Could not parse call arguments or missing \')\''); + return; } - str = str.replace(/\r\n?/g, '\n'); - // Remove potential UTF Byte Order Mark - str = preText + str.replace(/^\uFEFF/, '') + modifyVars; - imports.contents[fileInfo.filename] = str; + parserInput.forget(); - // Start with the primary rule. - // The whole syntax tree is held under a Ruleset node, - // with the `root` property set to true, so no `{}` are - // output. The callback is called when the input is parsed. - try { - parserInput.start(str, context.chunkInput, function fail(msg, index) { - throw new LessError({ - index, - type: 'Parse', - message: msg, - filename: fileInfo.filename - }, imports); - }); + return new(Call)(name, args, index + currentIndex, fileInfo); + } - tree.Node.prototype.parse = this; - root = new tree.Ruleset(null, this.parsers.primary()); - tree.Node.prototype.rootNode = root; - root.root = true; - root.firstRoot = true; - root.functionRegistry = functionRegistry.inherit(); + let pDeclarationCall = () => { + let validCall; + let args; + const index = parserInput.i; - } catch (e) { - return callback(new LessError(e, imports, fileInfo.filename)); + parserInput.save(); + + validCall = /** @type {string} */ (parserInput.$re(/^[\w]+\(/)); + if (!validCall) { + parserInput.forget(); + return; } - // If `i` is smaller than the `input.length - 1`, - // it means the parser wasn't able to parse the whole - // string, so we've got a parsing error. - // - // We try to extract a \n delimited string, - // showing the line where the parse error occurred. - // We split it up into two parts (the part which parsed, - // and the part which didn't), so we can color them differently. - const endInfo = parserInput.end(); - if (!endInfo.isFinished) { + validCall = validCall.substring(0, validCall.length - 1); - let message = endInfo.furthestPossibleErrorMessage; + let rule = parseRuleProperty(); + let value; + + if (rule) { + value = parseValue(); + } + + if (rule && value) { + args = [new (Declaration)(rule, value, null, null, parserInput.i + currentIndex, fileInfo, true)]; + } - if (!message) { - message = 'Unrecognised input'; - if (endInfo.furthestChar === '}') { - message += '. Possibly missing opening \'{\''; - } else if (endInfo.furthestChar === ')') { - message += '. Possibly missing opening \'(\''; - } else if (endInfo.furthestReachedEnd) { - message += '. Possibly missing something'; - } - } + if (!parserInput.$char(')')) { + parserInput.restore('Could not parse call arguments or missing \')\''); + return; + } - err = new LessError({ - type: 'Parse', - message, - index: endInfo.furthest, - filename: fileInfo.filename - }, imports); + parserInput.forget(); + + return new(Call)(validCall, args, index + currentIndex, fileInfo); + } + + // + // Parsing rules for functions with non-standard args, e.g.: + // + // boolean(not(2 > 1)) + // + // This is a quick prototype, to be modified/improved when + // more custom-parsed funcs come (e.g. `selector(...)`) + // + + /** + * @param {string} name + */ + let pCustomFuncCall = (name) => { + /* Ideally the table is to be moved out of here for faster perf., + but it's quite tricky since it relies on all these `parsers` + and `expect` available only here */ + return { + alpha: f(parseIeAlpha, true), + boolean: f(condition), + 'if': f(condition) + }[name.toLowerCase()]; + + /** + * + * @param {Function} parse + * @param {boolean} [stop] + */ + function f(parse, stop) { + return { + parse, // parsing function + stop // when true - stop after parse() and return its result, + // otherwise continue for plain args + }; } - const finish = e => { - e = err || e || imports.error; + function condition() { + return [expect(parsers.condition, 'expected condition')]; + } + } - if (e) { - if (!(e instanceof LessError)) { - e = new LessError(e, imports, fileInfo.filename); + /** + * @param {Array} [prevArgs] + */ + let pArguments = (prevArgs) => { + let argsComma = prevArgs || []; + const argsSemiColon = []; + let isSemiColonSeparated; + /** @type {*} */ + let value; + + parserInput.save(); + + while (true) { + if (prevArgs) { + prevArgs = undefined; + } else { + value = parseDetachedRuleset() || pAssignment() || parseExpression(); + if (!value) { + break; } - return callback(e); + if (value.value && value.value.length == 1) { + value = value.value[0]; + } + + argsComma.push(value); } - else { - return callback(null, root); + + if (parserInput.$char(',')) { + continue; } - }; - if (context.processImports !== false) { - new visitors.ImportVisitor(imports, finish) - .run(root); + if (parserInput.$char(';') || isSemiColonSeparated) { + isSemiColonSeparated = true; + value = (argsComma.length < 1) ? argsComma[0] + : new Value(argsComma); + argsSemiColon.push(value); + argsComma = []; + } + } + + parserInput.forget(); + return isSemiColonSeparated ? argsSemiColon : argsComma; + } + + let pLiteral = () => { + return pDimension() + || pColor() + || pQuoted() + || pUnicodeDescriptor(); + } + + // Assignments are argument entities for calls. + // They are present in ie filter properties as shown below. + // + // filter: progid:DXImageTransform.Microsoft.Alpha( *opacity=50* ) + // + let pAssignment = () => { + let key; + let value; + parserInput.save(); + key = parserInput.$re(/^\w+(?=\s?=)/i); + if (!key) { + parserInput.restore(); + return; + } + if (!parserInput.$char('=')) { + parserInput.restore(); + return; + } + value = parsers.entity(); + if (value) { + parserInput.forget(); + return new(Assignment)(key, value); } else { - return finish(); + parserInput.restore(); } - }, + } // - // Here in, the parsing rules/functions + // Parse url() tokens // - // The basic structure of the syntax tree generated is as follows: + // We use a specific rule for urls, because they don't really behave like + // standard function calls. The difference is that the argument doesn't have + // to be enclosed within a string, so it can't be parsed as an Expression. // - // Ruleset -> Declaration -> Value -> Expression -> Entity + let pUrl = () => { + /** @type {*} */ + let value; + const index = parserInput.i; + + parserInput.autoCommentAbsorb = false; + + if (!parserInput.$str('url(')) { + parserInput.autoCommentAbsorb = true; + return; + } + + value = pQuoted() || pVariable() || pProperty() || + parserInput.$re(/^(?:(?:\\[()'"])|[^()'"])+/) || ''; + + parserInput.autoCommentAbsorb = true; + + expectChar(')'); + + return new(URL)((value.value !== undefined || + value instanceof Variable || + value instanceof Property) ? + value : new(Anonymous)(value, index), index + currentIndex, fileInfo); + } + // - // Here's some Less code: + // A Variable entity, such as `@fink`, in // - // .class { - // color: #fff; - // border: 1px solid #000; - // width: @w + 4px; - // > .child {...} - // } + // width: @fink + 2px // - // And here's what the parse tree might look like: + // We use a different parser for variable definitions, + // see `parsers.variable`. // - // Ruleset (Selector '.class', [ - // Declaration ("color", Value ([Expression [Color #fff]])) - // Declaration ("border", Value ([Expression [Dimension 1px][Keyword "solid"][Color #000]])) - // Declaration ("width", Value ([Expression [Operation " + " [Variable "@w"][Dimension 4px]]])) - // Ruleset (Selector [Element '>', '.child'], [...]) - // ]) - // - // In general, most rules will try to parse a token with the `$re()` function, and if the return - // value is truly, will return a new node, of the relevant type. Sometimes, we need to check - // first, before parsing, that's when we use `peek()`. - // - parsers: parsers = { - // - // The `primary` rule is the *entry* and *exit* point of the parser. - // The rules here can appear at any level of the parse tree. - // - // The recursive nature of the grammar is an interplay between the `block` - // rule, which represents `{ ... }`, the `ruleset` rule, and this `primary` rule, - // as represented by this simplified grammar: - // - // primary → (ruleset | declaration)+ - // ruleset → selector+ block - // block → '{' primary '}' - // - // Only at one point is the primary rule not called from the - // block rule: at the root level. - // - primary: function () { - const mixin = this.mixin; - let root = []; - let node; - - while (true) { - while (true) { - node = this.comment(); - if (!node) { break; } - root.push(node); - } - // always process comments before deciding if finished - if (parserInput.finished) { - break; - } - if (parserInput.peek('}')) { - break; - } - - node = this.extendRule(); - if (node) { - root = root.concat(node); - continue; - } - - node = mixin.definition() || this.declaration() || mixin.call(false, false) || - this.ruleset() || this.variableCall() || this.entities.call() || this.atrule(); - if (node) { - root.push(node); - } else { - let foundSemiColon = false; - while (parserInput.$char(';')) { - foundSemiColon = true; - } - if (!foundSemiColon) { - break; - } + let pVariable = () => { + let ch; + let name; + const index = parserInput.i; + + parserInput.save(); + if (parserInput.currentChar() === '@' && (name = parserInput.$re(/^@@?[\w-]+/))) { + ch = parserInput.currentChar(); + if (ch === '(' || ch === '[' && !parserInput.prevChar().match(/^\s/)) { + // this may be a VariableCall lookup + const result = parsers.variableCall(name); + if (result) { + parserInput.forget(); + return result; } } + parserInput.forget(); + return new(Variable)(name, index + currentIndex, fileInfo); + } + parserInput.restore(); + } - return root; - }, - - // comments are collected by the main parsing mechanism and then assigned to nodes - // where the current structure allows it - comment: function () { - if (parserInput.commentStore.length) { - const comment = parserInput.commentStore.shift(); - return new(tree.Comment)(comment.text, comment.isLineComment, comment.index + currentIndex, fileInfo); - } - }, - - // - // Entities are tokens which can be found inside an Expression - // - entities: { - mixinLookup: function() { - return parsers.mixin.call(true, true); - }, - // - // A string, which supports escaping " and ' - // - // "milky way" 'he\'s the one!' - // - quoted: function (forceEscaped) { - let str; - const index = parserInput.i; - let isEscaped = false; + // A variable entity using the protective {} e.g. @{var} + let pVariableCurly = () => { + let curly; + const index = parserInput.i; - parserInput.save(); - if (parserInput.$char('~')) { - isEscaped = true; - } else if (forceEscaped) { - parserInput.restore(); - return; - } + if (parserInput.currentChar() === '@' && (curly = parserInput.$re(/^@\{([\w-]+)\}/))) { + return new(Variable)(`@${curly[1]}`, index + currentIndex, fileInfo); + } + } + // + // A Property accessor, such as `$color`, in + // + // background-color: $color + // + let pProperty = () => { + let name; + const index = parserInput.i; - str = parserInput.$quoted(); - if (!str) { - parserInput.restore(); - return; - } - parserInput.forget(); + if (parserInput.currentChar() === '$' && (name = parserInput.$re(/^\$[\w-]+/))) { + return new(Property)(name, index + currentIndex, fileInfo); + } + } - return new(tree.Quoted)(str.charAt(0), str.substr(1, str.length - 2), isEscaped, index + currentIndex, fileInfo); - }, - - // - // A catch-all word, such as: - // - // black border-collapse - // - keyword: function () { - const k = parserInput.$char('%') || parserInput.$re(/^\[?(?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+\]?/); - if (k) { - return tree.Color.fromKeyword(k) || new(tree.Keyword)(k); - } - }, - - // - // A function call - // - // rgb(255, 0, 255) - // - // The arguments are parsed with the `entities.arguments` parser. - // - call: function () { - let name; - let args; - let func; - const index = parserInput.i; - - // http://jsperf.com/case-insensitive-regex-vs-strtolower-then-regex/18 - if (parserInput.peek(/^url\(/i)) { - return; - } + // A property entity useing the protective {} e.g. ${prop} + let pPropertyCurly = () => { + let curly; + const index = parserInput.i; - parserInput.save(); + if (parserInput.currentChar() === '$' && (curly = parserInput.$re(/^\$\{([\w-]+)\}/))) { + return new(Property)(`$${curly[1]}`, index + currentIndex, fileInfo); + } + } + // + // A Hexadecimal color + // + // #4F3C2F + // + // `rgb` and `hsl` colors are parsed through the `entities.call` parser. + // + let pColor = () => { + let rgb; + parserInput.save(); - name = parserInput.$re(/^([\w-]+|%|~|progid:[\w.]+)\(/); - if (!name) { - parserInput.forget(); - return; - } + if (parserInput.currentChar() === '#' && (rgb = parserInput.$re(/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3,4})([\w.#[])?/))) { + if (!rgb[2]) { + parserInput.forget(); + return new(Color)(rgb[1], undefined, rgb[0]); + } + } + parserInput.restore(); + } - name = name[1]; - func = this.customFuncCall(name); - if (func) { - args = func.parse(); - if (args && func.stop) { - parserInput.forget(); - return args; - } - } + let pColorKeyword = () => { + parserInput.save(); + const autoCommentAbsorb = parserInput.autoCommentAbsorb; + parserInput.autoCommentAbsorb = false; + const k = parserInput.$re(/^[_A-Za-z-][_A-Za-z0-9-]+/); + parserInput.autoCommentAbsorb = autoCommentAbsorb; + if (!k) { + parserInput.forget(); + return; + } + parserInput.restore(); + const color = Color.fromKeyword(k); + if (color) { + parserInput.$str(/** @type {string} */ (k)); + return color; + } + } - args = this.arguments(args); + // + // A Dimension, that is, a number and a unit + // + // 0.5em 95% + // + let pDimension = () => { + if (parserInput.peekNotNumeric()) { + return; + } - if (!parserInput.$char(')')) { - parserInput.restore('Could not parse call arguments or missing \')\''); - return; - } + const value = parserInput.$re(/^([+-]?\d*\.?\d+)(%|[a-z_]+)?/i); + if (value) { + return new(Dimension)(value[1], value[2]); + } + } - parserInput.forget(); + // + // A unicode descriptor, as is used in unicode-range + // + // U+0?? or U+00A1-00A9 + // + let pUnicodeDescriptor = () => { + let ud; - return new(tree.Call)(name, args, index + currentIndex, fileInfo); - }, + ud = parserInput.$re(/^U\+[0-9a-fA-F?]+(-[0-9a-fA-F?]+)?/); + if (ud) { + return new(UnicodeDescriptor)(ud[0]); + } + } - declarationCall: function () { - let validCall; - let args; - const index = parserInput.i; + // + // JavaScript code to be evaluated + // + // `window.location.href` + // + let pJavascript = () => { + let js; + const index = parserInput.i; - parserInput.save(); + parserInput.save(); - validCall = parserInput.$re(/^[\w]+\(/); - if (!validCall) { - parserInput.forget(); - return; - } + const escape = parserInput.$char('~'); + const jsQuote = parserInput.$char('`'); - validCall = validCall.substring(0, validCall.length - 1); + if (!jsQuote) { + parserInput.restore(); + return; + } - let rule = this.ruleProperty(); - let value; - - if (rule) { - value = this.value(); - } - - if (rule && value) { - args = [new (tree.Declaration)(rule, value, null, null, parserInput.i + currentIndex, fileInfo, true)]; - } + js = parserInput.$re(/^[^`]*`/); + if (js) { + parserInput.forget(); + return new(JavaScript)(js.substr(0, js.length - 1), Boolean(escape), index + currentIndex, fileInfo); + } + parserInput.restore('invalid javascript definition'); + } + - if (!parserInput.$char(')')) { - parserInput.restore('Could not parse call arguments or missing \')\''); - return; - } + // + // The variable part of a variable definition. Used in the `rule` parser + // + // @fink: + // + let parseVariable = () => { + let name; - parserInput.forget(); + if (parserInput.currentChar() === '@' && (name = parserInput.$re(/^(@[\w-]+)\s*:/))) { return name[1]; } + } - return new(tree.Call)(validCall, args, index + currentIndex, fileInfo); - }, - - // - // Parsing rules for functions with non-standard args, e.g.: - // - // boolean(not(2 > 1)) - // - // This is a quick prototype, to be modified/improved when - // more custom-parsed funcs come (e.g. `selector(...)`) - // - - customFuncCall: function (name) { - /* Ideally the table is to be moved out of here for faster perf., - but it's quite tricky since it relies on all these `parsers` - and `expect` available only here */ - return { - alpha: f(parsers.ieAlpha, true), - boolean: f(condition), - 'if': f(condition) - }[name.toLowerCase()]; - - function f(parse, stop) { - return { - parse, // parsing function - stop // when true - stop after parse() and return its result, - // otherwise continue for plain args - }; - } + // + // Call a variable value to retrieve a detached ruleset + // or a value from a detached ruleset's rules. + // + // @fink(); + // @fink; + // color: @fink[@color]; + // + /** + * @param {string} [parsedName] + */ + let parseVariableCall = (parsedName) => { + let lookups; + const i = parserInput.i; + const inValue = !!parsedName; + /** @type {string | null | undefined} */ + let name = parsedName; - function condition() { - return [expect(parsers.condition, 'expected condition')]; - } - }, + parserInput.save(); - arguments: function (prevArgs) { - let argsComma = prevArgs || []; - const argsSemiColon = []; - let isSemiColonSeparated; - let value; + if (name || (parserInput.currentChar() === '@' + && (name = parserInput.$re(/^(@[\w-]+)(\(\s*\))?/)))) { - parserInput.save(); + lookups = mRuleLookups(); - while (true) { - if (prevArgs) { - prevArgs = false; - } else { - value = parsers.detachedRuleset() || this.assignment() || parsers.expression(); - if (!value) { - break; - } + if (!lookups && ((inValue && parserInput.$str('()') !== '()') || (name[2] !== '()'))) { + parserInput.restore('Missing \'[...]\' lookup in variable call'); + return; + } - if (value.value && value.value.length == 1) { - value = value.value[0]; - } + if (!inValue) { + name = name[1]; + } - argsComma.push(value); - } + const call = new VariableCall(name, i, fileInfo); + if (!inValue && parsers.end()) { + parserInput.forget(); + return call; + } + else { + parserInput.forget(); + return new NamespaceValue(call, lookups, i, fileInfo); + } + } - if (parserInput.$char(',')) { - continue; - } + parserInput.restore(); + } - if (parserInput.$char(';') || isSemiColonSeparated) { - isSemiColonSeparated = true; - value = (argsComma.length < 1) ? argsComma[0] - : new tree.Value(argsComma); - argsSemiColon.push(value); - argsComma = []; - } - } + // + // extend syntax - used to extend selectors + // + /** + * @param {boolean} [isRule] + * + * @returns {Array> | undefined} + */ + let parseExtend = (isRule) => { + let elements; + let e; + const index = parserInput.i; + let option; + let extendList; + let extend; + + if (!parserInput.$str(isRule ? '&:extend(' : ':extend(')) { + return; + } - parserInput.forget(); - return isSemiColonSeparated ? argsSemiColon : argsComma; - }, - literal: function () { - return this.dimension() || - this.color() || - this.quoted() || - this.unicodeDescriptor(); - }, - - // Assignments are argument entities for calls. - // They are present in ie filter properties as shown below. - // - // filter: progid:DXImageTransform.Microsoft.Alpha( *opacity=50* ) - // - - assignment: function () { - let key; - let value; - parserInput.save(); - key = parserInput.$re(/^\w+(?=\s?=)/i); - if (!key) { - parserInput.restore(); - return; - } - if (!parserInput.$char('=')) { - parserInput.restore(); - return; + do { + option = null; + elements = null; + while (!(option = parserInput.$re(/^(all)(?=\s*(\)|,))/))) { + e = parseElement(); + if (!e) { + break; } - value = parsers.entity(); - if (value) { - parserInput.forget(); - return new(tree.Assignment)(key, value); + if (elements) { + elements.push(e); } else { - parserInput.restore(); - } - }, - - // - // Parse url() tokens - // - // We use a specific rule for urls, because they don't really behave like - // standard function calls. The difference is that the argument doesn't have - // to be enclosed within a string, so it can't be parsed as an Expression. - // - url: function () { - let value; - const index = parserInput.i; - - parserInput.autoCommentAbsorb = false; - - if (!parserInput.$str('url(')) { - parserInput.autoCommentAbsorb = true; - return; + elements = [ e ]; } + } + + option = option && option[1]; + if (!elements) { + error('Missing target selector for :extend().'); + } + extend = new(Extend)(new(Selector)(elements), option, index + currentIndex, fileInfo); + if (extendList) { + extendList.push(extend); + } else { + extendList = [ extend ]; + } + } while (parserInput.$char(',')); - value = this.quoted() || this.variable() || this.property() || - parserInput.$re(/^(?:(?:\\[()'"])|[^()'"])+/) || ''; + expect(/^\)/); - parserInput.autoCommentAbsorb = true; + if (isRule) { + expect(/^;/); + } + + return extendList; + } + + // + // extendRule - used in a rule to extend all the parent selectors + // + let parseExtendRule = () => parseExtend(true); + // + // Mixins + // Mixin parsing functions prefaced with `m` + // + // + // A Mixin call, with an optional argument list + // + // #mixins > .square(#fff); + // #mixins.square(#fff); + // .rounded(4px, black); + // .button; + // + // We can lookup / return a value using the lookup syntax: + // + // color: #mixin.square(#fff)[@color]; + // + // The `while` loop is there because mixins can be + // namespaced, but we only support the child and descendant + // selector for now. + // + /** + * + * @param {boolean} [inValue] + * @param {boolean} [getLookup] + */ + let mCall = (inValue, getLookup) => { + const s = parserInput.currentChar(); + let important = false; + let lookups; + const index = parserInput.i; + let elements; + let args; + let hasParens; + + if (s !== '.' && s !== '#') { return; } + + parserInput.save(); // stop us absorbing part of an invalid selector + + elements = mElements(); + + if (elements) { + if (parserInput.$char('(')) { + args = mArgs(true).args; expectChar(')'); + hasParens = true; + } - return new(tree.URL)((value.value !== undefined || - value instanceof tree.Variable || - value instanceof tree.Property) ? - value : new(tree.Anonymous)(value, index), index + currentIndex, fileInfo); - }, - - // - // A Variable entity, such as `@fink`, in - // - // width: @fink + 2px - // - // We use a different parser for variable definitions, - // see `parsers.variable`. - // - variable: function () { - let ch; - let name; - const index = parserInput.i; + if (getLookup !== false) { + lookups = mRuleLookups(); + } + if (getLookup === true && !lookups) { + parserInput.restore(); + return; + } - parserInput.save(); - if (parserInput.currentChar() === '@' && (name = parserInput.$re(/^@@?[\w-]+/))) { - ch = parserInput.currentChar(); - if (ch === '(' || ch === '[' && !parserInput.prevChar().match(/^\s/)) { - // this may be a VariableCall lookup - const result = parsers.variableCall(name); - if (result) { - parserInput.forget(); - return result; - } - } - parserInput.forget(); - return new(tree.Variable)(name, index + currentIndex, fileInfo); - } + if (inValue && !lookups && !hasParens) { + // This isn't a valid in-value mixin call parserInput.restore(); - }, + return; + } - // A variable entity using the protective {} e.g. @{var} - variableCurly: function () { - let curly; - const index = parserInput.i; + if (!inValue && parsers.important()) { + important = true; + } - if (parserInput.currentChar() === '@' && (curly = parserInput.$re(/^@\{([\w-]+)\}/))) { - return new(tree.Variable)(`@${curly[1]}`, index + currentIndex, fileInfo); + if (inValue || parsers.end()) { + parserInput.forget(); + let mixin = new MixinCall(elements, args, index + currentIndex, fileInfo, !lookups && important); + if (lookups) { + return new NamespaceValue(mixin, lookups); } - }, - // - // A Property accessor, such as `$color`, in - // - // background-color: $color - // - property: function () { - let name; - const index = parserInput.i; - - if (parserInput.currentChar() === '$' && (name = parserInput.$re(/^\$[\w-]+/))) { - return new(tree.Property)(name, index + currentIndex, fileInfo); + else { + return mixin; } - }, - - // A property entity useing the protective {} e.g. ${prop} - propertyCurly: function () { - let curly; - const index = parserInput.i; + } + } - if (parserInput.currentChar() === '$' && (curly = parserInput.$re(/^\$\{([\w-]+)\}/))) { - return new(tree.Property)(`$${curly[1]}`, index + currentIndex, fileInfo); - } - }, - // - // A Hexadecimal color - // - // #4F3C2F - // - // `rgb` and `hsl` colors are parsed through the `entities.call` parser. - // - color: function () { - let rgb; - parserInput.save(); + parserInput.restore(); + } + /** + * Matching elements for mixins + * (Start with . or # and can have > ) + */ + let mElements = () => { + let elements; + let e; + let c; + let elem; + let elemIndex; + const re = /^[#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/; + while (true) { + elemIndex = parserInput.i; + e = parserInput.$re(re); - if (parserInput.currentChar() === '#' && (rgb = parserInput.$re(/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3,4})([\w.#[])?/))) { - if (!rgb[2]) { - parserInput.forget(); - return new(tree.Color)(rgb[1], undefined, rgb[0]); + if (!e) { + break; + } + elem = new(Element)(c, e, false, elemIndex + currentIndex, fileInfo); + if (elements) { + elements.push(elem); + } else { + elements = [ elem ]; + } + c = parserInput.$char('>'); + } + return elements; + } + /** + * @param {boolean} [isCall] + */ + let mArgs = (isCall) => { + /** @type {{ args: Array | null, variadic: boolean }} */ + const returner = { args: null, variadic: false } + let expressions = []; + /** + * @type {Array<{ + * name?: any + * value?: any + * expand?: boolean | undefined + * variadic?: boolean | undefined + * }>} + */ + const argsSemiColon = []; + const argsComma = []; + let isSemiColonSeparated; + let expressionContainsNamed; + let name; + let nameLoop; + let value; + /** @type {any} */ + let arg; + let expand; + let hasSep = true; + + parserInput.save(); + + while (true) { + if (isCall) { + arg = parsers.detachedRuleset() || parsers.expression(); + } else { + parserInput.commentStore.length = 0; + if (parserInput.$str('...')) { + returner.variadic = true; + if (parserInput.$char(';') && !isSemiColonSeparated) { + isSemiColonSeparated = true; } + (isSemiColonSeparated ? argsSemiColon : argsComma) + .push({ variadic: true }); + break; } - parserInput.restore(); - }, - - colorKeyword: function () { - parserInput.save(); - const autoCommentAbsorb = parserInput.autoCommentAbsorb; - parserInput.autoCommentAbsorb = false; - const k = parserInput.$re(/^[_A-Za-z-][_A-Za-z0-9-]+/); - parserInput.autoCommentAbsorb = autoCommentAbsorb; - if (!k) { - parserInput.forget(); - return; - } - parserInput.restore(); - const color = tree.Color.fromKeyword(k); - if (color) { - parserInput.$str(k); - return color; - } - }, - - // - // A Dimension, that is, a number and a unit - // - // 0.5em 95% - // - dimension: function () { - if (parserInput.peekNotNumeric()) { - return; - } + arg = pVariable() || pProperty() || pLiteral() || pKeyword() || mCall(true); + } - const value = parserInput.$re(/^([+-]?\d*\.?\d+)(%|[a-z_]+)?/i); - if (value) { - return new(tree.Dimension)(value[1], value[2]); - } - }, - - // - // A unicode descriptor, as is used in unicode-range - // - // U+0?? or U+00A1-00A9 - // - unicodeDescriptor: function () { - let ud; - - ud = parserInput.$re(/^U\+[0-9a-fA-F?]+(-[0-9a-fA-F?]+)?/); - if (ud) { - return new(tree.UnicodeDescriptor)(ud[0]); - } - }, + if (!arg || !hasSep) { + break; + } - // - // JavaScript code to be evaluated - // - // `window.location.href` - // - javascript: function () { - let js; - const index = parserInput.i; + nameLoop = null; + if (arg.throwAwayComments) { + arg.throwAwayComments(); + } + value = arg; + let val = null; - parserInput.save(); + if (isCall) { + // Variable + if (arg.value && arg.value.length == 1) { + val = arg.value[0]; + } + } else { + val = arg; + } - const escape = parserInput.$char('~'); - const jsQuote = parserInput.$char('`'); + if (val && (val instanceof Variable || val instanceof Property)) { + if (parserInput.$char(':')) { + if (expressions.length > 0) { + if (isSemiColonSeparated) { + error('Cannot mix ; and , as delimiter types'); + } + expressionContainsNamed = true; + } - if (!jsQuote) { - parserInput.restore(); - return; - } + value = parsers.detachedRuleset() || parsers.expression(); - js = parserInput.$re(/^[^`]*`/); - if (js) { - parserInput.forget(); - return new(tree.JavaScript)(js.substr(0, js.length - 1), Boolean(escape), index + currentIndex, fileInfo); + if (!value) { + if (isCall) { + error('could not understand value for named argument'); + } else { + parserInput.restore(); + returner.args = []; + return returner; + } + } + nameLoop = (name = /** @type {*} */ (val).name); + } else if (parserInput.$str('...')) { + if (!isCall) { + returner.variadic = true; + if (parserInput.$char(';') && !isSemiColonSeparated) { + isSemiColonSeparated = true; + } + (isSemiColonSeparated ? argsSemiColon : argsComma) + .push({ name: arg.name, variadic: true }); + break; + } else { + expand = true; + } + } else if (!isCall) { + name = nameLoop = /** @type {*} */ (val).name; + value = null; } - parserInput.restore('invalid javascript definition'); } - }, - - // - // The variable part of a variable definition. Used in the `rule` parser - // - // @fink: - // - variable: function () { - let name; - - if (parserInput.currentChar() === '@' && (name = parserInput.$re(/^(@[\w-]+)\s*:/))) { return name[1]; } - }, - // - // Call a variable value to retrieve a detached ruleset - // or a value from a detached ruleset's rules. - // - // @fink(); - // @fink; - // color: @fink[@color]; - // - variableCall: function (parsedName) { - let lookups; - const i = parserInput.i; - const inValue = !!parsedName; - let name = parsedName; + if (value) { + expressions.push(value); + } - parserInput.save(); + argsComma.push({ name:nameLoop, value, expand }); - if (name || (parserInput.currentChar() === '@' - && (name = parserInput.$re(/^(@[\w-]+)(\(\s*\))?/)))) { + if (parserInput.$char(',')) { + hasSep = true; + continue; + } + hasSep = parserInput.$char(';') === ';'; - lookups = this.mixin.ruleLookups(); + if (hasSep || isSemiColonSeparated) { - if (!lookups && ((inValue && parserInput.$str('()') !== '()') || (name[2] !== '()'))) { - parserInput.restore('Missing \'[...]\' lookup in variable call'); - return; + if (expressionContainsNamed) { + error('Cannot mix ; and , as delimiter types'); } - if (!inValue) { - name = name[1]; - } + isSemiColonSeparated = true; - const call = new tree.VariableCall(name, i, fileInfo); - if (!inValue && parsers.end()) { - parserInput.forget(); - return call; - } - else { - parserInput.forget(); - return new tree.NamespaceValue(call, lookups, i, fileInfo); + if (expressions.length > 1) { + value = new(Value)(expressions); } + argsSemiColon.push({ name, value, expand }); + + name = null; + expressions = []; + expressionContainsNamed = false; } + } - parserInput.restore(); - }, + parserInput.forget(); + returner.args = isSemiColonSeparated ? argsSemiColon : argsComma; + return returner; + } + // + // A Mixin definition, with a list of parameters + // + // .rounded (@radius: 2px, @color) { + // ... + // } + // + // Until we have a finer grained state-machine, we have to + // do a look-ahead, to make sure we don't have a mixin call. + // See the `rule` function for more information. + // + // We start by matching `.rounded (`, and then proceed on to + // the argument list, which has optional default values. + // We store the parameters in `params`, with a `value` key, + // if there is a value, such as in the case of `@radius`. + // + // Once we've got our params list, and a closing `)`, we parse + // the `{...}` block. + // + let mDefinition = () => { + let name; + /** @type {Array | null} */ + let params = []; + let match; + let ruleset; + let cond; + let variadic = false; + if ((parserInput.currentChar() !== '.' && parserInput.currentChar() !== '#') || + parserInput.peek(/^[^{]*\}/)) { + return; + } - // - // extend syntax - used to extend selectors - // - extend: function(isRule) { - let elements; - let e; - const index = parserInput.i; - let option; - let extendList; - let extend; - - if (!parserInput.$str(isRule ? '&:extend(' : ':extend(')) { + parserInput.save(); + + match = parserInput.$re(/^([#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+)\s*\(/); + if (match) { + name = match[1]; + + const argInfo = mArgs(false); + params = argInfo.args; + variadic = argInfo.variadic; + + // .mixincall("@{a}"); + // looks a bit like a mixin definition.. + // also + // .mixincall(@a: {rule: set;}); + // so we have to be nice and restore + if (!parserInput.$char(')')) { + parserInput.restore('Missing closing \')\''); return; } - do { - option = null; - elements = null; - while (!(option = parserInput.$re(/^(all)(?=\s*(\)|,))/))) { - e = this.element(); - if (!e) { - break; - } - if (elements) { - elements.push(e); - } else { - elements = [ e ]; - } - } + parserInput.commentStore.length = 0; - option = option && option[1]; - if (!elements) { - error('Missing target selector for :extend().'); - } - extend = new(tree.Extend)(new(tree.Selector)(elements), option, index + currentIndex, fileInfo); - if (extendList) { - extendList.push(extend); - } else { - extendList = [ extend ]; - } - } while (parserInput.$char(',')); + if (parserInput.$str('when')) { // Guard + cond = expect(parsers.conditions, 'expected condition'); + } - expect(/^\)/); + ruleset = parsers.block(); - if (isRule) { - expect(/^;/); + if (ruleset) { + parserInput.forget(); + return new(MixinDefinition)(name, params, ruleset, cond, variadic); + } else { + parserInput.restore(); } + } else { + parserInput.restore(); + } + } - return extendList; - }, + let mRuleLookups = () => { + let rule; + const lookups = []; - // - // extendRule - used in a rule to extend all the parent selectors - // - extendRule: function() { - return this.extend(true); - }, + if (parserInput.currentChar() !== '[') { + return; + } - // - // Mixins - // - mixin: { - // - // A Mixin call, with an optional argument list - // - // #mixins > .square(#fff); - // #mixins.square(#fff); - // .rounded(4px, black); - // .button; - // - // We can lookup / return a value using the lookup syntax: - // - // color: #mixin.square(#fff)[@color]; - // - // The `while` loop is there because mixins can be - // namespaced, but we only support the child and descendant - // selector for now. - // - call: function (inValue, getLookup) { - const s = parserInput.currentChar(); - let important = false; - let lookups; - const index = parserInput.i; - let elements; - let args; - let hasParens; - - if (s !== '.' && s !== '#') { return; } - - parserInput.save(); // stop us absorbing part of an invalid selector - - elements = this.elements(); + while (true) { + parserInput.save(); + rule = mLookupValue(); + if (!rule && rule !== '') { + parserInput.restore(); + break; + } + lookups.push(rule); + parserInput.forget(); + } + if (lookups.length > 0) { + return lookups; + } + } - if (elements) { - if (parserInput.$char('(')) { - args = this.args(true).args; - expectChar(')'); - hasParens = true; - } + let mLookupValue = () => { + parserInput.save(); - if (getLookup !== false) { - lookups = this.ruleLookups(); - } - if (getLookup === true && !lookups) { - parserInput.restore(); - return; - } + if (!parserInput.$char('[')) { + parserInput.restore(); + return; + } - if (inValue && !lookups && !hasParens) { - // This isn't a valid in-value mixin call - parserInput.restore(); - return; - } + const name = parserInput.$re(/^(?:[@$]{0,2})[_a-zA-Z0-9-]*/); - if (!inValue && parsers.important()) { - important = true; - } + if (!parserInput.$char(']')) { + parserInput.restore(); + return; + } - if (inValue || parsers.end()) { - parserInput.forget(); - const mixin = new(tree.mixin.Call)(elements, args, index + currentIndex, fileInfo, !lookups && important); - if (lookups) { - return new tree.NamespaceValue(mixin, lookups); - } - else { - return mixin; - } - } - } + if (name || name === '') { + parserInput.forget(); + return name; + } - parserInput.restore(); - }, - /** - * Matching elements for mixins - * (Start with . or # and can have > ) - */ - elements: function() { - let elements; - let e; - let c; - let elem; - let elemIndex; - const re = /^[#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/; - while (true) { - elemIndex = parserInput.i; - e = parserInput.$re(re); - - if (!e) { - break; - } - elem = new(tree.Element)(c, e, false, elemIndex + currentIndex, fileInfo); - if (elements) { - elements.push(elem); - } else { - elements = [ elem ]; - } - c = parserInput.$char('>'); - } - return elements; - }, - args: function (isCall) { - const entities = parsers.entities; - const returner = { args:null, variadic: false }; - let expressions = []; - const argsSemiColon = []; - const argsComma = []; - let isSemiColonSeparated; - let expressionContainsNamed; - let name; - let nameLoop; - let value; - let arg; - let expand; - let hasSep = true; + parserInput.restore(); + } + + // + // Entities are the smallest recognized token, + // and can be found inside a rule's value. + // + let parseEntity = () => { + return parseComment() + || pLiteral() + || pVariable() + || pUrl() + || pProperty() + || pCall() + || pKeyword() + || mCall(true) + || pJavascript(); + } - parserInput.save(); + // + // A Declaration terminator. Note that we use `peek()` to check for '}', + // because the `block` rule will be expecting it, but we still need to make sure + // it's there, if ';' was omitted. + // + let parseEnd = () => parserInput.$char(';') || parserInput.peek('}'); - while (true) { - if (isCall) { - arg = parsers.detachedRuleset() || parsers.expression(); - } else { - parserInput.commentStore.length = 0; - if (parserInput.$str('...')) { - returner.variadic = true; - if (parserInput.$char(';') && !isSemiColonSeparated) { - isSemiColonSeparated = true; - } - (isSemiColonSeparated ? argsSemiColon : argsComma) - .push({ variadic: true }); - break; - } - arg = entities.variable() || entities.property() || entities.literal() || entities.keyword() || this.call(true); - } + // + // IE's alpha function + // + // alpha(opacity=88) + // + let parseIeAlpha = () => { + let value; + + // http://jsperf.com/case-insensitive-regex-vs-strtolower-then-regex/18 + if (!parserInput.$re(/^opacity=/i)) { return; } + value = parserInput.$re(/^\d+/); + if (!value) { + value = expect(parsers.entities.variable, 'Could not parse alpha'); + value = `@{${value.name.slice(1)}}`; + } + expectChar(')'); + return new Quoted('', `alpha(opacity=${value})`); + } - if (!arg || !hasSep) { - break; - } + // + // A Selector Element + // + // div + // + h1 + // #socks + // input[type="text"] + // + // Elements are the building blocks for Selectors, + // they are made out of a `Combinator` (see combinator rule), + // and an element name, such as a tag a class, or `*`. + // + /** + * @returns {InstanceType | undefined} + */ + let parseElement = () => { + let e; + let c; + let v; + const index = parserInput.i; + + c = parseCombinator(); + + e = parserInput.$re(/^(?:\d+\.\d+|\d+)%/) || + // eslint-disable-next-line no-control-regex + parserInput.$re(/^(?:[.#]?|:*)(?:[\w-]|[^\x00-\x9f]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/) || + parserInput.$char('*') || parserInput.$char('&') || parseAttribute() || + parserInput.$re(/^\([^&()@]+\)/) || parserInput.$re(/^[.#:](?=@)/) || + pVariableCurly(); + + if (!e) { + parserInput.save(); + if (parserInput.$char('(')) { + if ((v = parseSelector(false)) && parserInput.$char(')')) { + e = new(Paren)(v); + parserInput.forget(); + } else { + parserInput.restore('Missing closing \')\''); + } + } else { + parserInput.forget(); + } + } - nameLoop = null; - if (arg.throwAwayComments) { - arg.throwAwayComments(); - } - value = arg; - let val = null; + if (e) { return new(Element)(c, e, e instanceof Variable, index + currentIndex, fileInfo); } + } - if (isCall) { - // Variable - if (arg.value && arg.value.length == 1) { - val = arg.value[0]; - } - } else { - val = arg; - } + // + // Combinators combine elements together, in a Selector. + // + // Because our parser isn't white-space sensitive, special care + // has to be taken, when parsing the descendant combinator, ` `, + // as it's an empty space. We have to check the previous character + // in the input, to see if it's a ` ` character. More info on how + // we deal with this in *combinator.js*. + // + let parseCombinator = () => { + let c = parserInput.currentChar(); - if (val && (val instanceof tree.Variable || val instanceof tree.Property)) { - if (parserInput.$char(':')) { - if (expressions.length > 0) { - if (isSemiColonSeparated) { - error('Cannot mix ; and , as delimiter types'); - } - expressionContainsNamed = true; - } - - value = parsers.detachedRuleset() || parsers.expression(); - - if (!value) { - if (isCall) { - error('could not understand value for named argument'); - } else { - parserInput.restore(); - returner.args = []; - return returner; - } - } - nameLoop = (name = val.name); - } else if (parserInput.$str('...')) { - if (!isCall) { - returner.variadic = true; - if (parserInput.$char(';') && !isSemiColonSeparated) { - isSemiColonSeparated = true; - } - (isSemiColonSeparated ? argsSemiColon : argsComma) - .push({ name: arg.name, variadic: true }); - break; - } else { - expand = true; - } - } else if (!isCall) { - name = nameLoop = val.name; - value = null; - } - } + if (c === '/') { + parserInput.save(); + const slashedCombinator = parserInput.$re(/^\/[a-z]+\//i); + if (slashedCombinator) { + parserInput.forget(); + return new(Combinator)(slashedCombinator); + } + parserInput.restore(); + } - if (value) { - expressions.push(value); - } + if (c === '>' || c === '+' || c === '~' || c === '|' || c === '^') { + parserInput.i++; + if (c === '^' && parserInput.currentChar() === '^') { + c = '^^'; + parserInput.i++; + } + while (parserInput.isWhitespace()) { parserInput.i++; } + return new(Combinator)(c); + } else if (parserInput.isWhitespace(-1)) { + return new(Combinator)(' '); + } else { + return new(Combinator)(null); + } + } + // + // A CSS Selector + // with less extensions e.g. the ability to extend and guard + // + // .class > div + h1 + // li a:hover + // + // Selectors are made out of one or more Elements, see above. + // + /** + * + * @param {boolean} [isLess] + */ + let parseSelector = (isLess) => { + const index = parserInput.i; + let elements; + let extendList; + let c; + let e; + let allExtends; + let when; + let condition; + isLess = isLess !== false; + while ((isLess && (extendList = parseExtend())) || (isLess && (when = parserInput.$str('when'))) || (e = parseElement())) { + if (when) { + condition = expect(parseConditions, 'expected condition'); + } else if (condition) { + error('CSS guard can only be used at the end of selector'); + } else if (extendList) { + if (allExtends) { + allExtends = allExtends.concat(extendList); + } else { + allExtends = extendList; + } + } else { + if (allExtends) { error('Extend can only be used at the end of selector'); } + c = parserInput.currentChar(); + if (elements) { + elements.push(e); + } else { + elements = [ e ]; + } + e = null; + } + if (c === '{' || c === '}' || c === ';' || c === ',' || c === ')') { + break; + } + } - argsComma.push({ name:nameLoop, value, expand }); + if (elements) { return new(Selector)(elements, allExtends, condition, index + currentIndex, fileInfo); } + if (allExtends) { error('Extend must be used to extend a selector, it cannot be used on its own'); } + } + let parseSelectors = () => { + let s; + let selectors; + while (true) { + s = parseSelector(); + if (!s) { + break; + } + if (selectors) { + selectors.push(s); + } else { + selectors = [ s ]; + } + parserInput.commentStore.length = 0; + if (s.condition && selectors.length > 1) { + error('Guards are only currently allowed on a single selector.'); + } + if (!parserInput.$char(',')) { break; } + if (s.condition) { + error('Guards are only currently allowed on a single selector.'); + } + parserInput.commentStore.length = 0; + } + return selectors; + } + let parseAttribute = () => { + if (!parserInput.$char('[')) { return; } - if (parserInput.$char(',')) { - hasSep = true; - continue; - } - hasSep = parserInput.$char(';') === ';'; + let key; + let val; + let op; + // + // case-insensitive flag + // e.g. [attr operator value i] + // + let cif; - if (hasSep || isSemiColonSeparated) { + if (!(key = pVariableCurly())) { + key = expect(/^(?:[_A-Za-z0-9-*]*\|)?(?:[_A-Za-z0-9-]|\\.)+/); + } - if (expressionContainsNamed) { - error('Cannot mix ; and , as delimiter types'); - } + op = parserInput.$re(/^[|~*$^]?=/); + if (op) { + val = pQuoted() || parserInput.$re(/^[0-9]+%/) || parserInput.$re(/^[\w-]+/) || pVariableCurly(); + if (val) { + cif = parserInput.$re(/^[iIsS]/); + } + } - isSemiColonSeparated = true; + expectChar(']'); - if (expressions.length > 1) { - value = new(tree.Value)(expressions); - } - argsSemiColon.push({ name, value, expand }); + return new(Attribute)(key, op, val, cif); + } - name = null; - expressions = []; - expressionContainsNamed = false; - } - } + // + // The `block` rule is used by `ruleset` and `mixin.definition`. + // It's a wrapper around the `primary` rule, with added `{}`. + // + let parseBlock = () => { + let content; + if (parserInput.$char('{') && (content = parsePrimary()) && parserInput.$char('}')) { + return content; + } + } - parserInput.forget(); - returner.args = isSemiColonSeparated ? argsSemiColon : argsComma; - return returner; - }, - // - // A Mixin definition, with a list of parameters - // - // .rounded (@radius: 2px, @color) { - // ... - // } - // - // Until we have a finer grained state-machine, we have to - // do a look-ahead, to make sure we don't have a mixin call. - // See the `rule` function for more information. - // - // We start by matching `.rounded (`, and then proceed on to - // the argument list, which has optional default values. - // We store the parameters in `params`, with a `value` key, - // if there is a value, such as in the case of `@radius`. - // - // Once we've got our params list, and a closing `)`, we parse - // the `{...}` block. - // - definition: function () { - let name; - let params = []; - let match; - let ruleset; - let cond; - let variadic = false; - if ((parserInput.currentChar() !== '.' && parserInput.currentChar() !== '#') || - parserInput.peek(/^[^{]*\}/)) { - return; - } + /** + * @returns {InstanceType | undefined} + */ + let parseBlockRuleset = () => { + /** @type {any} */ + let block = parseBlock(); - parserInput.save(); + if (block) { + block = new Ruleset(null, block); + } + return block; + } - match = parserInput.$re(/^([#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+)\s*\(/); - if (match) { - name = match[1]; - - const argInfo = this.args(false); - params = argInfo.args; - variadic = argInfo.variadic; - - // .mixincall("@{a}"); - // looks a bit like a mixin definition.. - // also - // .mixincall(@a: {rule: set;}); - // so we have to be nice and restore - if (!parserInput.$char(')')) { - parserInput.restore('Missing closing \')\''); - return; - } + let parseDetachedRuleset = () => { + let argInfo; + let params; + let variadic; - parserInput.commentStore.length = 0; + parserInput.save(); + if (parserInput.$re(/^[.#]\(/)) { + /** + * DR args currently only implemented for each() function, and not + * yet settable as `@dr: #(@arg) {}` + * This should be done when DRs are merged with mixins. + * See: https://github.com/less/less-meta/issues/16 + */ + argInfo = mArgs(false); + params = argInfo.args; + variadic = argInfo.variadic; + if (!parserInput.$char(')')) { + parserInput.restore(); + return; + } + } + const blockRuleset = parseBlockRuleset(); + if (blockRuleset) { + parserInput.forget(); + if (params) { + return new MixinDefinition(null, params, blockRuleset, null, variadic); + } + return new DetachedRuleset(blockRuleset); + } + parserInput.restore(); + } - if (parserInput.$str('when')) { // Guard - cond = expect(parsers.conditions, 'expected condition'); - } + // + // div, .class, body > p {...} + // + let parseRuleset = () => { + let selectors; + let rules; + let debugInfo; - ruleset = parsers.block(); + parserInput.save(); - if (ruleset) { - parserInput.forget(); - return new(tree.mixin.Definition)(name, params, ruleset, cond, variadic); - } else { - parserInput.restore(); - } - } else { - parserInput.restore(); - } - }, + if (context.dumpLineNumbers) { + debugInfo = getDebugInfo(parserInput.i); + } - ruleLookups: function() { - let rule; - const lookups = []; + selectors = parseSelectors(); - if (parserInput.currentChar() !== '[') { - return; + if (selectors && (rules = parseBlock())) { + parserInput.forget(); + const ruleset = new(Ruleset)(selectors, rules, context.strictImports); + if (context.dumpLineNumbers) { + /** @type {*} */ (ruleset).debugInfo = debugInfo; + } + return ruleset; + } else { + parserInput.restore(); + } + } + let parseDeclaration = () => { + let name; + let value; + const index = parserInput.i; + let hasDR; + const c = parserInput.currentChar(); + let important; + let merge; + let isVariable; + + if (c === '.' || c === '#' || c === '&' || c === ':') { return; } + + parserInput.save(); + + name = parseVariable() || parseRuleProperty(); + if (name) { + isVariable = typeof name === 'string'; + + if (isVariable) { + value = parseDetachedRuleset(); + if (value) { + hasDR = true; } + } - while (true) { - parserInput.save(); - rule = this.lookupValue(); - if (!rule && rule !== '') { - parserInput.restore(); - break; - } - lookups.push(rule); - parserInput.forget(); + parserInput.commentStore.length = 0; + if (!value) { + // a name returned by this.ruleProperty() is always an array of the form: + // [string-1, ..., string-n, ""] or [string-1, ..., string-n, "+"] + // where each item is a Keyword or Variable + merge = !isVariable && name.length > 1 && /** @type {any[]} */ (name).pop().value; + + // Custom property values get permissive parsing + if (name[0].value && name[0].value.slice(0, 2) === '--') { + value = parsePermissiveValue(/[;}]/); } - if (lookups.length > 0) { - return lookups; + // Try to store values as anonymous + // If we need the value later we'll re-parse it in ruleset.parseValue + else { + value = parseAnonymousValue(); } - }, - - lookupValue: function() { - parserInput.save(); - - if (!parserInput.$char('[')) { - parserInput.restore(); - return; + if (value) { + parserInput.forget(); + // anonymous values absorb the end ';' which is required for them to work + return new(Declaration)(name, value, false, merge, index + currentIndex, fileInfo); } - const name = parserInput.$re(/^(?:[@$]{0,2})[_a-zA-Z0-9-]*/); - - if (!parserInput.$char(']')) { - parserInput.restore(); - return; + if (!value) { + value = parseValue(); } - if (name || name === '') { - parserInput.forget(); - return name; + if (value) { + important = parseImportant(); + } else if (isVariable) { + // As a last resort, try permissiveValue + value = parsePermissiveValue(); } + } + if (value && (parseEnd() || hasDR)) { + parserInput.forget(); + return new(Declaration)(name, value, important, merge, index + currentIndex, fileInfo); + } + else { parserInput.restore(); } - }, - // - // Entities are the smallest recognized token, - // and can be found inside a rule's value. - // - entity: function () { - const entities = this.entities; + } else { + parserInput.restore(); + } + } + let parseAnonymousValue = () => { + const index = parserInput.i; + let match = parserInput.$re(/^([^.#@$+/'"*`(;{}-]*);/); + if (match) { + return new(Anonymous)(match[1], index + currentIndex); + } + } + /** + * Used for custom properties, at-rules, and variables (as fallback) + * Parses almost anything inside of {} [] () "" blocks + * until it reaches outer-most tokens. + * + * First, it will try to parse comments and entities to reach + * the end. This is mostly like the Expression parser except no + * math is allowed. + * + * @param {RegExp | string} [untilTokens] + */ + let parsePermissiveValue = (untilTokens) => { + let i; + let e; + let done; + let value; + const tok = untilTokens || ';'; + const index = parserInput.i; + const result = []; + + function testCurrentChar() { + const char = parserInput.currentChar(); + if (typeof tok === 'string') { + return char === tok; + } else { + return tok.test(char); + } + } + if (testCurrentChar()) { + return; + } + value = []; + do { + e = parseComment(); + if (e) { + value.push(e); + continue; + } + e = parseEntity(); + if (e) { + value.push(e); + } + if (parserInput.peek(',')) { + value.push(new (Anonymous)(',', parserInput.i)); + parserInput.$char(','); + } + } while (e); - return this.comment() || entities.literal() || entities.variable() || entities.url() || - entities.property() || entities.call() || entities.keyword() || this.mixin.call(true) || - entities.javascript(); - }, + done = testCurrentChar(); - // - // A Declaration terminator. Note that we use `peek()` to check for '}', - // because the `block` rule will be expecting it, but we still need to make sure - // it's there, if ';' was omitted. - // - end: function () { - return parserInput.$char(';') || parserInput.peek('}'); - }, + if (value.length > 0) { + value = new(Expression)(value); + if (done) { + return value; + } + else { + result.push(value); + } + // Preserve space before $parseUntil as it will not + if (parserInput.prevChar() === ' ') { + result.push(new Anonymous(' ', index)); + } + } + parserInput.save(); - // - // IE's alpha function - // - // alpha(opacity=88) - // - ieAlpha: function () { - let value; + value = parserInput.$parseUntil(tok); - // http://jsperf.com/case-insensitive-regex-vs-strtolower-then-regex/18 - if (!parserInput.$re(/^opacity=/i)) { return; } - value = parserInput.$re(/^\d+/); - if (!value) { - value = expect(parsers.entities.variable, 'Could not parse alpha'); - value = `@{${value.name.slice(1)}}`; + if (value) { + if (typeof value === 'string') { + error(`Expected '${value}'`, 'Parse'); } - expectChar(')'); - return new tree.Quoted('', `alpha(opacity=${value})`); - }, - - // - // A Selector Element - // - // div - // + h1 - // #socks - // input[type="text"] - // - // Elements are the building blocks for Selectors, - // they are made out of a `Combinator` (see combinator rule), - // and an element name, such as a tag a class, or `*`. - // - element: function () { - let e; - let c; - let v; - const index = parserInput.i; - - c = this.combinator(); - - e = parserInput.$re(/^(?:\d+\.\d+|\d+)%/) || - // eslint-disable-next-line no-control-regex - parserInput.$re(/^(?:[.#]?|:*)(?:[\w-]|[^\x00-\x9f]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/) || - parserInput.$char('*') || parserInput.$char('&') || this.attribute() || - parserInput.$re(/^\([^&()@]+\)/) || parserInput.$re(/^[.#:](?=@)/) || - this.entities.variableCurly(); - - if (!e) { - parserInput.save(); - if (parserInput.$char('(')) { - if ((v = this.selector(false)) && parserInput.$char(')')) { - e = new(tree.Paren)(v); - parserInput.forget(); - } else { - parserInput.restore('Missing closing \')\''); + if (value.length === 1 && value[0] === ' ') { + parserInput.forget(); + return new Anonymous('', index); + } + let item; + for (i = 0; i < value.length; i++) { + item = value[i]; + if (Array.isArray(item)) { + // Treat actual quotes as normal quoted values + result.push(new Quoted(item[0], item[1], true, index, fileInfo)); + } + else { + if (i === value.length - 1) { + item = item.trim(); } - } else { - parserInput.forget(); + // Treat like quoted values, but replace vars like unquoted expressions + const quote = new Quoted('\'', item, true, index, fileInfo); + quote.variableRegex = /@([\w-]+)/g; + quote.propRegex = /\$([\w-]+)/g; + result.push(quote); } } + parserInput.forget(); + return new Expression(result, true); + } + parserInput.restore(); + } - if (e) { return new(tree.Element)(c, e, e instanceof tree.Variable, index + currentIndex, fileInfo); } - }, + // + // An @import atrule + // + // @import "lib"; + // + // Depending on our environment, importing is done differently: + // In the browser, it's an XHR request, in Node, it would be a + // file-system operation. The function used for importing is + // stored in `import`, which we pass to the Import constructor. + // + let parseImport = () => { + let path; + let features; + const index = parserInput.i; - // - // Combinators combine elements together, in a Selector. - // - // Because our parser isn't white-space sensitive, special care - // has to be taken, when parsing the descendant combinator, ` `, - // as it's an empty space. We have to check the previous character - // in the input, to see if it's a ` ` character. More info on how - // we deal with this in *combinator.js*. - // - combinator: function () { - let c = parserInput.currentChar(); + const dir = parserInput.$re(/^@import\s+/); - if (c === '/') { - parserInput.save(); - const slashedCombinator = parserInput.$re(/^\/[a-z]+\//i); - if (slashedCombinator) { - parserInput.forget(); - return new(tree.Combinator)(slashedCombinator); + if (dir) { + const options = (dir ? parseImportOptions() : null) || {}; + + if ((path = pQuoted() || pUrl())) { + features = parseMediaFeatures({}); + + if (!parserInput.$char(';')) { + parserInput.i = index; + error('missing semi-colon or unrecognised media features on import'); } - parserInput.restore(); + features = features && new(Value)(features); + return new(Import)(path, features, options, index + currentIndex, fileInfo); + } + else { + parserInput.i = index; + error('malformed import statement'); } + } + } - if (c === '>' || c === '+' || c === '~' || c === '|' || c === '^') { - parserInput.i++; - if (c === '^' && parserInput.currentChar() === '^') { - c = '^^'; - parserInput.i++; + let parseImportOptions = () => { + let o; + /** @type {Record} */ + const options = {}; + let optionName; + let value; + + // list of options, surrounded by parens + if (!parserInput.$char('(')) { return null; } + do { + o = parseImportOption(); + if (o) { + optionName = o; + value = true; + switch (optionName) { + case 'css': + optionName = 'less'; + value = false; + break; + case 'once': + optionName = 'multiple'; + value = false; + break; } - while (parserInput.isWhitespace()) { parserInput.i++; } - return new(tree.Combinator)(c); - } else if (parserInput.isWhitespace(-1)) { - return new(tree.Combinator)(' '); - } else { - return new(tree.Combinator)(null); + options[optionName] = value; + if (!parserInput.$char(',')) { break; } } - }, - // - // A CSS Selector - // with less extensions e.g. the ability to extend and guard - // - // .class > div + h1 - // li a:hover - // - // Selectors are made out of one or more Elements, see above. - // - selector: function (isLess) { - const index = parserInput.i; - let elements; - let extendList; - let c; - let e; - let allExtends; - let when; - let condition; - isLess = isLess !== false; - while ((isLess && (extendList = this.extend())) || (isLess && (when = parserInput.$str('when'))) || (e = this.element())) { - if (when) { - condition = expect(this.conditions, 'expected condition'); - } else if (condition) { - error('CSS guard can only be used at the end of selector'); - } else if (extendList) { - if (allExtends) { - allExtends = allExtends.concat(extendList); - } else { - allExtends = extendList; + } while (o); + expectChar(')'); + return options; + } + + let parseImportOption = () => { + const opt = parserInput.$re(/^(less|css|multiple|once|inline|reference|optional)/); + if (opt) { + return opt[1]; + } + } + + /** + * @param {Record} syntaxOptions + */ + let parseMediaFeature = (syntaxOptions) => { + const nodes = []; + let e; + /** @type {*} */ + let p; + let rangeP; + parserInput.save(); + do { + e = pDeclarationCall() || pKeyword() || pVariable() || pMixinLookup() + if (e) { + nodes.push(e); + } else if (parserInput.$char('(')) { + p = parseProperty(); + parserInput.save(); + if (!p && syntaxOptions.queryInParens && parserInput.$re(/^[0-9a-z-]*\s*([<>]=|<=|>=|[<>]|=)/)) { + parserInput.restore(); + p = parseCondition(); + + parserInput.save(); + rangeP = parseAtomicCondition(null, p.rvalue); + if (!rangeP) { + parserInput.restore(); } } else { - if (allExtends) { error('Extend can only be used at the end of selector'); } - c = parserInput.currentChar(); - if (elements) { - elements.push(e); + parserInput.restore(); + e = parseValue(); + } + if (parserInput.$char(')')) { + if (p && !e) { + nodes.push(new (Paren)(new (QueryInParens)(p.op, p.lvalue, p.rvalue, rangeP ? rangeP.op : null, rangeP ? rangeP.rvalue : null, p._index))); + e = p; + } else if (p && e) { + nodes.push(new (Paren)(new (Declaration)(p, e, null, null, parserInput.i + currentIndex, fileInfo, true))); + } else if (e) { + nodes.push(new(Paren)(e)); } else { - elements = [ e ]; + error('badly formed media feature definition'); } - e = null; - } - if (c === '{' || c === '}' || c === ';' || c === ',' || c === ')') { - break; + } else { + error('Missing closing \')\'', 'Parse'); } } + } while (e); - if (elements) { return new(tree.Selector)(elements, allExtends, condition, index + currentIndex, fileInfo); } - if (allExtends) { error('Extend must be used to extend a selector, it cannot be used on its own'); } - }, - selectors: function () { - let s; - let selectors; - while (true) { - s = this.selector(); - if (!s) { - break; - } - if (selectors) { - selectors.push(s); - } else { - selectors = [ s ]; - } - parserInput.commentStore.length = 0; - if (s.condition && selectors.length > 1) { - error('Guards are only currently allowed on a single selector.'); - } + parserInput.forget(); + if (nodes.length > 0) { + return new(Expression)(nodes); + } + } + + /** + * @param {Record} syntaxOptions + */ + let parseMediaFeatures = (syntaxOptions) => { + const features = []; + let e; + do { + e = parseMediaFeature(syntaxOptions); + if (e) { + features.push(e); if (!parserInput.$char(',')) { break; } - if (s.condition) { - error('Guards are only currently allowed on a single selector.'); + } else { + e = pVariable() || pMixinLookup(); + if (e) { + features.push(e); + if (!parserInput.$char(',')) { break; } } - parserInput.commentStore.length = 0; } - return selectors; - }, - attribute: function () { - if (!parserInput.$char('[')) { return; } + } while (e); - const entities = this.entities; - let key; - let val; - let op; - // - // case-insensitive flag - // e.g. [attr operator value i] - // - let cif; + return features.length > 0 ? features : null; + } - if (!(key = entities.variableCurly())) { - key = expect(/^(?:[_A-Za-z0-9-*]*\|)?(?:[_A-Za-z0-9-]|\\.)+/); - } + /** + * + * @param {new (...args: any[]) => Record} treeType + * @param {number} index + * @param {Record | undefined} debugInfo + * @param {Record} syntaxOptions + */ + let prepareAndGetNestableAtRule = (treeType, index, debugInfo, syntaxOptions) => { + const features = parseMediaFeatures(syntaxOptions); - op = parserInput.$re(/^[|~*$^]?=/); - if (op) { - val = entities.quoted() || parserInput.$re(/^[0-9]+%/) || parserInput.$re(/^[\w-]+/) || entities.variableCurly(); - if (val) { - cif = parserInput.$re(/^[iIsS]/); - } - } + const rules = parseBlock(); - expectChar(']'); + if (!rules) { + error('nested at-rules require block statements after any features'); + } - return new(tree.Attribute)(key, op, val, cif); - }, + parserInput.forget(); - // - // The `block` rule is used by `ruleset` and `mixin.definition`. - // It's a wrapper around the `primary` rule, with added `{}`. - // - block: function () { - let content; - if (parserInput.$char('{') && (content = this.primary()) && parserInput.$char('}')) { - return content; - } - }, + const atRule = new (treeType)(rules, features, index + currentIndex, fileInfo); + if (context.dumpLineNumbers) { + atRule.debugInfo = debugInfo; + } - blockRuleset: function() { - let block = this.block(); + return atRule; + } - if (block) { - block = new tree.Ruleset(null, block); - } - return block; - }, + let parseNestableAtRule = () => { + let debugInfo; + const index = parserInput.i; - detachedRuleset: function() { - let argInfo; - let params; - let variadic; + if (context.dumpLineNumbers) { + debugInfo = getDebugInfo(index); + } + parserInput.save(); - parserInput.save(); - if (parserInput.$re(/^[.#]\(/)) { - /** - * DR args currently only implemented for each() function, and not - * yet settable as `@dr: #(@arg) {}` - * This should be done when DRs are merged with mixins. - * See: https://github.com/less/less-meta/issues/16 - */ - argInfo = this.mixin.args(false); - params = argInfo.args; - variadic = argInfo.variadic; - if (!parserInput.$char(')')) { - parserInput.restore(); - return; - } + if (parserInput.$peekChar('@')) { + if (parserInput.$str('@media')) { + return prepareAndGetNestableAtRule(Media, index, debugInfo, MediaSyntaxOptions); } - const blockRuleset = this.blockRuleset(); - if (blockRuleset) { - parserInput.forget(); - if (params) { - return new tree.mixin.Definition(null, params, blockRuleset, null, variadic); - } - return new tree.DetachedRuleset(blockRuleset); + + if (parserInput.$str('@container')) { + return prepareAndGetNestableAtRule(Container, index, debugInfo, ContainerSyntaxOptions); } - parserInput.restore(); - }, - - // - // div, .class, body > p {...} - // - ruleset: function () { - let selectors; - let rules; - let debugInfo; + } + + parserInput.restore(); + } - parserInput.save(); + // - if (context.dumpLineNumbers) { - debugInfo = getDebugInfo(parserInput.i); + // A @plugin directive, used to import plugins dynamically. + // + // @plugin (args) "lib"; + // + let parsePlugin = () => { + let path; + let args; + let options; + const index = parserInput.i; + const dir = parserInput.$re(/^@plugin\s+/); + + if (dir) { + args = parsePluginArgs(); + + if (args) { + options = { + pluginArgs: args, + isPlugin: true + }; + } + else { + options = { isPlugin: true }; } - selectors = this.selectors(); + if ((path = pQuoted() || pUrl())) { - if (selectors && (rules = this.block())) { - parserInput.forget(); - const ruleset = new(tree.Ruleset)(selectors, rules, context.strictImports); - if (context.dumpLineNumbers) { - ruleset.debugInfo = debugInfo; + if (!parserInput.$char(';')) { + parserInput.i = index; + error('missing semi-colon on @plugin'); } - return ruleset; - } else { - parserInput.restore(); + return new(Import)(path, null, options, index + currentIndex, fileInfo); } - }, - declaration: function () { - let name; - let value; - const index = parserInput.i; - let hasDR; - const c = parserInput.currentChar(); - let important; - let merge; - let isVariable; - - if (c === '.' || c === '#' || c === '&' || c === ':') { return; } + else { + parserInput.i = index; + error('malformed @plugin statement'); + } + } + } - parserInput.save(); + let parsePluginArgs = () => { + // list of options, surrounded by parens + parserInput.save(); + if (!parserInput.$char('(')) { + parserInput.restore(); + return null; + } + const args = parserInput.$re(/^\s*([^);]+)\)\s*/); + if (args?.[1]) { + parserInput.forget(); + return args[1].trim(); + } + else { + parserInput.restore(); + return null; + } + } - name = this.variable() || this.ruleProperty(); - if (name) { - isVariable = typeof name === 'string'; + // + // A CSS AtRule + // + // @charset "utf-8"; + // + let parseAtrule = () => { + const index = parserInput.i; + let name; + let value; + let rules; + let nonVendorSpecificName; + let hasIdentifier; + let hasExpression; + let hasOptionalExpression; + let hasUnknown; + let hasBlock = true; + let isRooted = true; + + if (parserInput.currentChar() !== '@') { return; } + + value = parseImport() || parsePlugin() || parseNestableAtRule(); + if (value) { + return value; + } - if (isVariable) { - value = this.detachedRuleset(); - if (value) { - hasDR = true; - } - } + parserInput.save(); - parserInput.commentStore.length = 0; - if (!value) { - // a name returned by this.ruleProperty() is always an array of the form: - // [string-1, ..., string-n, ""] or [string-1, ..., string-n, "+"] - // where each item is a tree.Keyword or tree.Variable - merge = !isVariable && name.length > 1 && name.pop().value; - - // Custom property values get permissive parsing - if (name[0].value && name[0].value.slice(0, 2) === '--') { - value = this.permissiveValue(/[;}]/); - } - // Try to store values as anonymous - // If we need the value later we'll re-parse it in ruleset.parseValue - else { - value = this.anonymousValue(); - } - if (value) { - parserInput.forget(); - // anonymous values absorb the end ';' which is required for them to work - return new(tree.Declaration)(name, value, false, merge, index + currentIndex, fileInfo); - } + name = parserInput.$re(/^@[a-z-]+/); - if (!value) { - value = this.value(); - } + if (!name) { return; } - if (value) { - important = this.important(); - } else if (isVariable) { - // As a last resort, try permissiveValue - value = this.permissiveValue(); - } - } + nonVendorSpecificName = name; + if (name.charAt(1) == '-' && name.indexOf('-', 2) > 0) { + nonVendorSpecificName = `@${name.slice(name.indexOf('-', 2) + 1)}`; + } - if (value && (this.end() || hasDR)) { - parserInput.forget(); - return new(tree.Declaration)(name, value, important, merge, index + currentIndex, fileInfo); - } - else { - parserInput.restore(); - } - } else { - parserInput.restore(); + switch (nonVendorSpecificName) { + case '@charset': + hasIdentifier = true; + hasBlock = false; + break; + case '@namespace': + hasExpression = true; + hasBlock = false; + break; + case '@keyframes': + case '@counter-style': + hasIdentifier = true; + break; + /** @todo require optional keyword for `@layer` */ + case '@document': + case '@supports': + hasUnknown = true; + isRooted = false; + break; + case '@layer': + hasOptionalExpression = true; + isRooted = false; + break; + default: + hasUnknown = true; + break; + } + + parserInput.commentStore.length = 0; + + if (hasIdentifier) { + value = parseEntity(); + if (!value) { + error(`expected ${name} identifier`); } - }, - anonymousValue: function () { - const index = parserInput.i; - const match = parserInput.$re(/^([^.#@$+/'"*`(;{}-]*);/); - if (match) { - return new(tree.Anonymous)(match[1], index + currentIndex); + } else if (hasExpression || hasOptionalExpression) { + value = parseExpression(); + if (hasExpression && !value) { + error(`expected ${name} expression`); } - }, - /** - * Used for custom properties, at-rules, and variables (as fallback) - * Parses almost anything inside of {} [] () "" blocks - * until it reaches outer-most tokens. - * - * First, it will try to parse comments and entities to reach - * the end. This is mostly like the Expression parser except no - * math is allowed. - */ - permissiveValue: function (untilTokens) { - let i; - let e; - let done; - let value; - const tok = untilTokens || ';'; - const index = parserInput.i; - const result = []; - - function testCurrentChar() { - const char = parserInput.currentChar(); - if (typeof tok === 'string') { - return char === tok; - } else { - return tok.test(char); + } else if (hasUnknown) { + value = parsePermissiveValue(/^[{;]/); + hasBlock = (parserInput.currentChar() === '{'); + if (!value) { + if (!hasBlock && parserInput.currentChar() !== ';') { + error(`${name} rule is missing block or ending semi-colon`); } } - if (testCurrentChar()) { - return; + else if (!value.value) { + value = null; } - value = []; - do { - e = this.comment(); - if (e) { - value.push(e); - continue; - } - e = this.entity(); - if (e) { - value.push(e); - } - if (parserInput.peek(',')) { - value.push(new (tree.Anonymous)(',', parserInput.i)); - parserInput.$char(','); - } - } while (e); + } - done = testCurrentChar(); + if (hasBlock) { + rules = parseBlockRuleset(); + } - if (value.length > 0) { - value = new(tree.Expression)(value); - if (done) { - return value; - } - else { - result.push(value); - } - // Preserve space before $parseUntil as it will not - if (parserInput.prevChar() === ' ') { - result.push(new tree.Anonymous(' ', index)); - } - } - parserInput.save(); + if (rules || (!hasBlock && value && parserInput.$char(';'))) { + parserInput.forget(); + return new(AtRule)(name, value, rules, index + currentIndex, fileInfo, + context.dumpLineNumbers ? getDebugInfo(index) : null, + isRooted + ); + } - value = parserInput.$parseUntil(tok); + parserInput.restore('at-rule options not recognised'); + } - if (value) { - if (typeof value === 'string') { - error(`Expected '${value}'`, 'Parse'); - } - if (value.length === 1 && value[0] === ' ') { - parserInput.forget(); - return new tree.Anonymous('', index); - } - let item; - for (i = 0; i < value.length; i++) { - item = value[i]; - if (Array.isArray(item)) { - // Treat actual quotes as normal quoted values - result.push(new tree.Quoted(item[0], item[1], true, index, fileInfo)); - } - else { - if (i === value.length - 1) { - item = item.trim(); - } - // Treat like quoted values, but replace vars like unquoted expressions - const quote = new tree.Quoted('\'', item, true, index, fileInfo); - quote.variableRegex = /@([\w-]+)/g; - quote.propRegex = /\$([\w-]+)/g; - result.push(quote); - } - } + // + // A Value is a comma-delimited list of Expressions + // + // font-family: Baskerville, Georgia, serif; + // + // In a Rule, a Value represents everything after the `:`, + // and before the `;`. + // + let parseValue = () => { + let e; + const expressions = []; + + do { + e = parseExpression(); + if (e) { + expressions.push(e); + if (!parserInput.$char(',')) { break; } + } + } while (e); + + if (expressions.length > 0) { + return new(Value)(expressions); + } + } + let parseImportant = () => { + if (parserInput.currentChar() === '!') { + return parserInput.$re(/^! *important/); + } + } + let parseSub = () => { + let a; + let e; + + parserInput.save(); + if (parserInput.$char('(')) { + a = parseAddition(); + if (a && parserInput.$char(')')) { parserInput.forget(); - return new tree.Expression(result, true); + e = new(Expression)([a]); + e.parens = true; + return e; } - parserInput.restore(); - }, + parserInput.restore('Expected \')\''); + return; + } + parserInput.restore(); + } + let parseMultiplication = () => { + let m; + let a; + let op; + let operation; + let isSpaced; + m = parseOperand(); + if (m) { + isSpaced = parserInput.isWhitespace(-1); + while (true) { + if (parserInput.peek(/^\/[*/]/)) { + break; + } - // - // An @import atrule - // - // @import "lib"; - // - // Depending on our environment, importing is done differently: - // In the browser, it's an XHR request, in Node, it would be a - // file-system operation. The function used for importing is - // stored in `import`, which we pass to the Import constructor. - // - 'import': function () { - let path; - let features; - const index = parserInput.i; + parserInput.save(); - const dir = parserInput.$re(/^@import\s+/); + op = parserInput.$char('/') || parserInput.$char('*') || parserInput.$str('./'); - if (dir) { - const options = (dir ? this.importOptions() : null) || {}; + if (!op) { parserInput.forget(); break; } - if ((path = this.entities.quoted() || this.entities.url())) { - features = this.mediaFeatures({}); + a = parseOperand(); - if (!parserInput.$char(';')) { - parserInput.i = index; - error('missing semi-colon or unrecognised media features on import'); - } - features = features && new(tree.Value)(features); - return new(tree.Import)(path, features, options, index + currentIndex, fileInfo); + if (!a) { parserInput.restore(); break; } + parserInput.forget(); + + m.parensInOp = true; + a.parensInOp = true; + operation = new(Operation)(op, [operation || m, a], isSpaced); + isSpaced = parserInput.isWhitespace(-1); + } + return operation || m; + } + } + let parseAddition = () => { + let m; + let a; + let op; + let operation; + let isSpaced; + m = parseMultiplication(); + if (m) { + isSpaced = parserInput.isWhitespace(-1); + while (true) { + op = parserInput.$re(/^[-+]\s+/) || (!isSpaced && (parserInput.$char('+') || parserInput.$char('-'))); + if (!op) { + break; } - else { - parserInput.i = index; - error('malformed import statement'); + a = parseMultiplication(); + if (!a) { + break; } - } - }, - importOptions: function() { - let o; - const options = {}; - let optionName; - let value; - - // list of options, surrounded by parens - if (!parserInput.$char('(')) { return null; } - do { - o = this.importOption(); - if (o) { - optionName = o; - value = true; - switch (optionName) { - case 'css': - optionName = 'less'; - value = false; - break; - case 'once': - optionName = 'multiple'; - value = false; - break; - } - options[optionName] = value; - if (!parserInput.$char(',')) { break; } + m.parensInOp = true; + a.parensInOp = true; + operation = new(Operation)(op, [operation || m, a], isSpaced); + isSpaced = parserInput.isWhitespace(-1); + } + return operation || m; + } + } + let parseConditions = () => { + let a; + let b; + const index = parserInput.i; + let condition; + + a = parseCondition(true); + if (a) { + while (true) { + if (!parserInput.peek(/^,\s*(not\s*)?\(/) || !parserInput.$char(',')) { + break; } - } while (o); - expectChar(')'); - return options; - }, - - importOption: function() { - const opt = parserInput.$re(/^(less|css|multiple|once|inline|reference|optional)/); - if (opt) { - return opt[1]; + b = parseCondition(true); + if (!b) { + break; + } + condition = new(Condition)('or', condition || a, b, index + currentIndex); } - }, - - mediaFeature: function (syntaxOptions) { - const entities = this.entities; - const nodes = []; - let e; - let p; - let rangeP; - parserInput.save(); - do { - e = entities.declarationCall.bind(this)() || entities.keyword() || entities.variable() || entities.mixinLookup() - if (e) { - nodes.push(e); - } else if (parserInput.$char('(')) { - p = this.property(); - parserInput.save(); - if (!p && syntaxOptions.queryInParens && parserInput.$re(/^[0-9a-z-]*\s*([<>]=|<=|>=|[<>]|=)/)) { - parserInput.restore(); - p = this.condition(); + return condition || a; + } + } + /** + * @param {boolean} [needsParens] + * + * @returns {InstanceType | undefined} + */ + let parseCondition = (needsParens) => { + let result; + let logical; + let next; + function or() { + return parserInput.$str('or'); + } - parserInput.save(); - rangeP = this.atomicCondition(null, p.rvalue); - if (!rangeP) { - parserInput.restore(); - } - } else { - parserInput.restore(); - e = this.value(); - } - if (parserInput.$char(')')) { - if (p && !e) { - nodes.push(new (tree.Paren)(new (tree.QueryInParens)(p.op, p.lvalue, p.rvalue, rangeP ? rangeP.op : null, rangeP ? rangeP.rvalue : null, p._index))); - e = p; - } else if (p && e) { - nodes.push(new (tree.Paren)(new (tree.Declaration)(p, e, null, null, parserInput.i + currentIndex, fileInfo, true))); - } else if (e) { - nodes.push(new(tree.Paren)(e)); - } else { - error('badly formed media feature definition'); - } - } else { - error('Missing closing \')\'', 'Parse'); - } - } - } while (e); - - parserInput.forget(); - if (nodes.length > 0) { - return new(tree.Expression)(nodes); - } - }, - - mediaFeatures: function (syntaxOptions) { - const entities = this.entities; - const features = []; - let e; - do { - e = this.mediaFeature(syntaxOptions); - if (e) { - features.push(e); - if (!parserInput.$char(',')) { break; } - } else { - e = entities.variable() || entities.mixinLookup(); - if (e) { - features.push(e); - if (!parserInput.$char(',')) { break; } - } - } - } while (e); - - return features.length > 0 ? features : null; - }, - - prepareAndGetNestableAtRule: function (treeType, index, debugInfo, syntaxOptions) { - const features = this.mediaFeatures(syntaxOptions); - - const rules = this.block(); - - if (!rules) { - error('media definitions require block statements after any features'); + result = parseConditionAnd(needsParens); + if (!result) { + return ; + } + logical = or(); + if (logical) { + next = parseCondition(needsParens); + if (next) { + result = new(Condition)(logical, result, next); + } else { + return ; } + } + return result; + } + /** + * @param {boolean} [needsParens] + * + * @returns {InstanceType | undefined} + */ + let parseConditionAnd = (needsParens) => { + let result; + let logical; + let next; + function insideCondition() { + const cond = parseNegatedCondition(needsParens) || parseParenthesisCondition(needsParens); + if (!cond && !needsParens) { + return parseAtomicCondition(needsParens); + } + return cond; + } + function and() { + return parserInput.$str('and'); + } - parserInput.forget(); - - const atRule = new (treeType)(rules, features, index + currentIndex, fileInfo); - if (context.dumpLineNumbers) { - atRule.debugInfo = debugInfo; + result = insideCondition(); + if (!result) { + return ; + } + logical = and(); + if (logical) { + next = parseConditionAnd(needsParens); + if (next) { + result = new(Condition)(logical, result, next); + } else { + return ; } - - return atRule; - }, - - nestableAtRule: function () { - let debugInfo; - const index = parserInput.i; - - if (context.dumpLineNumbers) { - debugInfo = getDebugInfo(index); + } + return result; + } + /** + * @param {boolean} [needsParens] + */ + let parseNegatedCondition = (needsParens) => { + if (parserInput.$str('not')) { + const result = parseParenthesisCondition(needsParens); + if (result) { + result.negate = !result.negate; } + return result; + } + } + /** + * @param {boolean} [needsParens] + */ + let parseParenthesisCondition = (needsParens) => { + function tryConditionFollowedByParenthesis() { + let body; parserInput.save(); - - if (parserInput.$peekChar('@')) { - if (parserInput.$str('@media')) { - return this.prepareAndGetNestableAtRule(tree.Media, index, debugInfo, MediaSyntaxOptions); - } - - if (parserInput.$str('@container')) { - return this.prepareAndGetNestableAtRule(tree.Container, index, debugInfo, ContainerSyntaxOptions); - } + body = parseCondition(needsParens); + if (!body) { + parserInput.restore(); + return ; } - + if (!parserInput.$char(')')) { + parserInput.restore(); + return ; + } + parserInput.forget(); + return body; + } + + let body; + parserInput.save(); + if (!parserInput.$str('(')) { parserInput.restore(); - }, + return ; + } + body = tryConditionFollowedByParenthesis(); + if (body) { + parserInput.forget(); + return body; + } - // + body = parseAtomicCondition(needsParens); + if (!body) { + parserInput.restore(); + return ; + } + if (!parserInput.$char(')')) { + parserInput.restore(`expected ')' got '${parserInput.currentChar()}'`); + return ; + } + parserInput.forget(); + return body; + } + /** + * @param {boolean | null} [needsParens] + * @param {boolean} [preparsedCond] + */ + let parseAtomicCondition = (needsParens, preparsedCond) => { + const index = parserInput.i; + let a; + let b; + let c; + let op; + + const cond = () => parseAddition() + || pKeyword() + || pQuoted() + || pMixinLookup(); + + if (preparsedCond) { + a = preparsedCond; + } else { + a = cond(); + } - // A @plugin directive, used to import plugins dynamically. - // - // @plugin (args) "lib"; - // - plugin: function () { - let path; - let args; - let options; - const index = parserInput.i; - const dir = parserInput.$re(/^@plugin\s+/); - - if (dir) { - args = this.pluginArgs(); - - if (args) { - options = { - pluginArgs: args, - isPlugin: true - }; - } - else { - options = { isPlugin: true }; + if (a) { + if (parserInput.$char('>')) { + if (parserInput.$char('=')) { + op = '>='; + } else { + op = '>'; } - - if ((path = this.entities.quoted() || this.entities.url())) { - - if (!parserInput.$char(';')) { - parserInput.i = index; - error('missing semi-colon on @plugin'); - } - return new(tree.Import)(path, null, options, index + currentIndex, fileInfo); + } else + if (parserInput.$char('<')) { + if (parserInput.$char('=')) { + op = '<='; + } else { + op = '<'; } - else { - parserInput.i = index; - error('malformed @plugin statement'); + } else + if (parserInput.$char('=')) { + if (parserInput.$char('>')) { + op = '=>'; + } else if (parserInput.$char('<')) { + op = '=<'; + } else { + op = '='; } } - }, - - pluginArgs: function() { - // list of options, surrounded by parens - parserInput.save(); - if (!parserInput.$char('(')) { - parserInput.restore(); - return null; - } - const args = parserInput.$re(/^\s*([^);]+)\)\s*/); - if (args[1]) { - parserInput.forget(); - return args[1].trim(); - } - else { - parserInput.restore(); - return null; + if (op) { + b = cond(); + if (b) { + c = new(Condition)(op, a, b, index + currentIndex, false); + } else { + error('expected expression'); + } + } else if (!preparsedCond) { + c = new(Condition)('=', a, new(Keyword)('true'), index + currentIndex, false); } - }, + return c; + } + } - // - // A CSS AtRule - // - // @charset "utf-8"; - // - atrule: function () { - const index = parserInput.i; - let name; - let value; - let rules; - let nonVendorSpecificName; - let hasIdentifier; - let hasExpression; - let hasUnknown; - let hasBlock = true; - let isRooted = true; - - if (parserInput.currentChar() !== '@') { return; } - - value = this['import']() || this.plugin() || this.nestableAtRule(); - if (value) { - return value; - } + // + // An operand is anything that can be part of an operation, + // such as a Color, or a Variable + // + let parseOperand = () => { + let negate; - parserInput.save(); + if (parserInput.peek(/^-[@$(]/)) { + negate = parserInput.$char('-'); + } - name = parserInput.$re(/^@[a-z-]+/); + /** @type {*} */ + let o = parseSub() + || pDimension() + || pColor() + || pVariable() + || pProperty() + || pCall() + || pQuoted(true) + || pColorKeyword() + || pMixinLookup(); + + if (negate) { + o.parensInOp = true; + o = new(Negative)(o); + } - if (!name) { return; } + return o; + } - nonVendorSpecificName = name; - if (name.charAt(1) == '-' && name.indexOf('-', 2) > 0) { - nonVendorSpecificName = `@${name.slice(name.indexOf('-', 2) + 1)}`; + // + // Expressions either represent mathematical operations, + // or white-space delimited Entities. + // + // 1px solid black + // @var * 2 + // + let parseExpression = () => { + const entities = []; + let e; + let delim; + const index = parserInput.i; + + do { + e = parseComment(); + if (e) { + entities.push(e); + continue; } + e = parseAddition() || parseEntity(); - switch (nonVendorSpecificName) { - case '@charset': - hasIdentifier = true; - hasBlock = false; - break; - case '@namespace': - hasExpression = true; - hasBlock = false; - break; - case '@keyframes': - case '@counter-style': - hasIdentifier = true; - break; - case '@document': - case '@supports': - hasUnknown = true; - isRooted = false; - break; - default: - hasUnknown = true; - break; + if (e instanceof Comment) { + e = null; } - parserInput.commentStore.length = 0; - - if (hasIdentifier) { - value = this.entity(); - if (!value) { - error(`expected ${name} identifier`); - } - } else if (hasExpression) { - value = this.expression(); - if (!value) { - error(`expected ${name} expression`); - } - } else if (hasUnknown) { - value = this.permissiveValue(/^[{;]/); - hasBlock = (parserInput.currentChar() === '{'); - if (!value) { - if (!hasBlock && parserInput.currentChar() !== ';') { - error(`${name} rule is missing block or ending semi-colon`); + if (e) { + entities.push(e); + // operations do not allow keyword "/" dimension (e.g. small/20px) so we support that here + if (!parserInput.peek(/^\/[/*]/)) { + delim = parserInput.$char('/'); + if (delim) { + entities.push(new(Anonymous)(delim, index + currentIndex)); } } - else if (!value.value) { - value = null; - } } + } while (e); + if (entities.length > 0) { + return new(Expression)(entities); + } + } + let parseProperty = () => { + const name = parserInput.$re(/^(\*?-?[_a-zA-Z0-9-]+)\s*:/); + if (name) { + return name[1]; + } + } + let parseRuleProperty = () => { + /** @type {Array} */ + let name = []; + /** @type {Array} */ + const index = []; + let s; + let k; + + parserInput.save(); + + const simpleProperty = parserInput.$re(/^([_a-zA-Z0-9-]+)\s*:/); + if (simpleProperty) { + name = [new(Keyword)(simpleProperty[1])]; + parserInput.forget(); + return name; + } - if (hasBlock) { - rules = this.blockRuleset(); + /** + * @param {RegExp} re + */ + function match(re) { + const i = parserInput.i; + const chunk = parserInput.$re(re); + if (chunk) { + index.push(i); + return name.push(chunk[1]); } + } - if (rules || (!hasBlock && value && parserInput.$char(';'))) { - parserInput.forget(); - return new(tree.AtRule)(name, value, rules, index + currentIndex, fileInfo, - context.dumpLineNumbers ? getDebugInfo(index) : null, - isRooted - ); + match(/^(\*?)/); + while (true) { + if (!match(/^((?:[\w-]+)|(?:[@$]\{[\w-]+\}))/)) { + break; } + } - parserInput.restore('at-rule options not recognised'); - }, + if ((name.length > 1) && match(/^((?:\+_|\+)?)\s*:/)) { + parserInput.forget(); - // - // A Value is a comma-delimited list of Expressions - // - // font-family: Baskerville, Georgia, serif; - // - // In a Rule, a Value represents everything after the `:`, - // and before the `;`. - // - value: function () { - let e; - const expressions = []; - const index = parserInput.i; + // at last, we have the complete match now. move forward, + // convert name particles to tree objects and return: + if (name[0] === '') { + name.shift(); + index.shift(); + } + for (k = 0; k < name.length; k++) { + s = name[k]; + name[k] = (s.charAt(0) !== '@' && s.charAt(0) !== '$') ? + new(Keyword)(s) : + (s.charAt(0) === '@' ? + new(Variable)(`@${s.slice(2, -1)}`, index[k] + currentIndex, fileInfo) : + new(Property)(`$${s.slice(2, -1)}`, index[k] + currentIndex, fileInfo)); + } + return name; + } + parserInput.restore(); + } - do { - e = this.expression(); - if (e) { - expressions.push(e); - if (!parserInput.$char(',')) { break; } - } - } while (e); - if (expressions.length > 0) { - return new(tree.Value)(expressions, index + currentIndex); - } + /** + * @overload + * @param {'plugin'} fnName + * @param {typeof parsePlugin} newFn + * @return {void} + */ + + /** + * + * @param {string} fnName + * @param {(...args: any) => any} newFn + * @return {void} + */ + function override(fnName, newFn) { + switch (fnName) { + case 'plugin': + parsePlugin = newFn + break; + default: + error('Cannot override ' + fnName); + } + } + + return { + override, + entities: { + arguments: pArguments, + assignment: pAssignment, + call: pCall, + color: pColor, + colorKeyword: pColorKeyword, + customFuncCall: pCustomFuncCall, + declarationCall: pDeclarationCall, + dimension: pDimension, + javascript: pJavascript, + keyword: pKeyword, + literal: pLiteral, + mixinLookup: pMixinLookup, + property: pProperty, + propertyCurly: pPropertyCurly, + quoted: pQuoted, + unicodeDescriptor: pUnicodeDescriptor, + url: pUrl, + variable: pVariable, + variableCurly: pVariableCurly }, - important: function () { - if (parserInput.currentChar() === '!') { - return parserInput.$re(/^! *important/); - } + addition: parseAddition, + anonymousValue: parseAnonymousValue, + atrule: parseAtrule, + atomicCondition: parseAtomicCondition, + attribute: parseAttribute, + block: parseBlock, + blockRuleset: parseBlockRuleset, + combinator: parseCombinator, + comment: parseComment, + condition: parseCondition, + conditionAnd: parseConditionAnd, + conditions: parseConditions, + declaration: parseDeclaration, + detachedRuleset: parseDetachedRuleset, + element: parseElement, + expression: parseExpression, + end: parseEnd, + extend: parseExtend, + entity: parseEntity, + extendRule: parseExtendRule, + ieAlpha: parseIeAlpha, + 'import': parseImport, + importOption: parseImportOption, + importOptions: parseImportOptions, + important: parseImportant, + mediaFeature: parseMediaFeature, + mediaFeatures: parseMediaFeatures, + mixin: { + args: mArgs, + call: mCall, + definition: mDefinition, + elements: mElements, + lookupValue: mLookupValue, + ruleLookups: mRuleLookups }, - sub: function () { - let a; - let e; + multiplication: parseMultiplication, + negatedCondition: parseNegatedCondition, + nestableAtRule: parseNestableAtRule, + operand: parseOperand, + parenthesisCondition: parseParenthesisCondition, + permissiveValue: parsePermissiveValue, + plugin: parsePlugin, + pluginArgs: parsePluginArgs, + primary: parsePrimary, + property: parseProperty, + ruleset: parseRuleset, + selector: parseSelector, + selectors: parseSelectors, + sub: parseSub, + value: parseValue, + variable: parseVariable, + variableCall: parseVariableCall + } + + })() - parserInput.save(); - if (parserInput.$char('(')) { - a = this.addition(); - if (a && parserInput.$char(')')) { - parserInput.forget(); - e = new(tree.Expression)([a]); - e.parens = true; - return e; - } - parserInput.restore('Expected \')\''); - return; - } - parserInput.restore(); + let parserInput = getParserInput(); + + /** + * + * @param {string} msg + * @param {string} [type] + */ + function error(msg, type) { + throw new LessError( + { + index: parserInput.i, + filename: fileInfo.filename, + type: type || 'Syntax', + message: msg }, - multiplication: function () { - let m; - let a; - let op; - let operation; - let isSpaced; - m = this.operand(); - if (m) { - isSpaced = parserInput.isWhitespace(-1); - while (true) { - if (parserInput.peek(/^\/[*/]/)) { - break; - } + imports + ); + } - parserInput.save(); + /** + * + * @param {RegExp | Function} arg + * @param {string} [msg] + */ + function expect(arg, msg) { + // some older browsers return typeof 'function' for RegExp + const result = (arg instanceof Function) ? arg.call(parsers) : parserInput.$re(arg); + if (result) { + return result; + } - op = parserInput.$char('/') || parserInput.$char('*') || parserInput.$str('./'); + error(msg || (typeof arg === 'string' + ? `expected '${arg}' got '${parserInput.currentChar()}'` + : 'unexpected token')); + } - if (!op) { parserInput.forget(); break; } + /** + * Specialization of expect() + * + * @param {string} arg + * @param {string} [msg] + */ + function expectChar(arg, msg) { + if (parserInput.$char(arg)) { + return arg; + } + error(msg || `expected '${arg}' got '${parserInput.currentChar()}'`); + } - a = this.operand(); + /** + * @param {number} index + */ + function getDebugInfo(index) { + const filename = fileInfo.filename; - if (!a) { parserInput.restore(); break; } - parserInput.forget(); + return { + lineNumber: utils.getLocation(index, parserInput.getInput()).line + 1, + fileName: filename + }; + } - m.parensInOp = true; - a.parensInOp = true; - operation = new(tree.Operation)(op, [operation || m, a], isSpaced); - isSpaced = parserInput.isWhitespace(-1); - } - return operation || m; - } - }, - addition: function () { - let m; - let a; - let op; - let operation; - let isSpaced; - m = this.multiplication(); - if (m) { - isSpaced = parserInput.isWhitespace(-1); - while (true) { - op = parserInput.$re(/^[-+]\s+/) || (!isSpaced && (parserInput.$char('+') || parserInput.$char('-'))); - if (!op) { - break; - } - a = this.multiplication(); - if (!a) { - break; - } + /** + * Used after initial parsing to create nodes on the fly + * + * @param {String} str - string to parse + * @param {Array} parseList - array of parsers to run input through e.g. ["value", "important"] + * @param {Function} callback + */ + function parseNode(str, parseList, callback) { + let result; + const returnNodes = []; + let parser = parserInput; - m.parensInOp = true; - a.parensInOp = true; - operation = new(tree.Operation)(op, [operation || m, a], isSpaced); - isSpaced = parserInput.isWhitespace(-1); - } - return operation || m; - } - }, - conditions: function () { - let a; - let b; - const index = parserInput.i; - let condition; - - a = this.condition(true); - if (a) { - while (true) { - if (!parserInput.peek(/^,\s*(not\s*)?\(/) || !parserInput.$char(',')) { - break; - } - b = this.condition(true); - if (!b) { - break; - } - condition = new(tree.Condition)('or', condition || a, b, index + currentIndex); - } - return condition || a; - } - }, - condition: function (needsParens) { - let result; - let logical; - let next; - function or() { - return parserInput.$str('or'); - } + try { + parser.start(str, false, + /** + * + * @param {string} msg + * @param {number} index + */ + function fail(msg, index) { + callback({ + message: msg, + index: index + currentIndex + }); + }); + for (let x = 0, p; (p = parseList[x]); x++) { + result = /** @type {*} */ (parsers)[p](); + returnNodes.push(result || null); + } - result = this.conditionAnd(needsParens); - if (!result) { - return ; - } - logical = or(); - if (logical) { - next = this.condition(needsParens); - if (next) { - result = new(tree.Condition)(logical, result, next); - } else { - return ; - } - } - return result; - }, - conditionAnd: function (needsParens) { - let result; - let logical; - let next; - const self = this; - function insideCondition() { - const cond = self.negatedCondition(needsParens) || self.parenthesisCondition(needsParens); - if (!cond && !needsParens) { - return self.atomicCondition(needsParens); - } - return cond; - } - function and() { - return parserInput.$str('and'); - } + const endInfo = parser.end(); + if (endInfo.isFinished) { + callback(null, returnNodes); + } + else { + callback(true, null); + } + } catch (e) { + throw new LessError({ + index: e.index + currentIndex, + message: e.message + }, imports, fileInfo.filename); + } + } - result = insideCondition(); - if (!result) { - return ; - } - logical = and(); - if (logical) { - next = this.conditionAnd(needsParens); - if (next) { - result = new(tree.Condition)(logical, result, next); - } else { - return ; - } - } - return result; - }, - negatedCondition: function (needsParens) { - if (parserInput.$str('not')) { - const result = this.parenthesisCondition(needsParens); - if (result) { - result.negate = !result.negate; - } - return result; - } - }, - parenthesisCondition: function (needsParens) { - function tryConditionFollowedByParenthesis(me) { - let body; - parserInput.save(); - body = me.condition(needsParens); - if (!body) { - parserInput.restore(); - return ; - } - if (!parserInput.$char(')')) { - parserInput.restore(); - return ; - } - parserInput.forget(); - return body; - } + // + // The Parser + // + return { + parserInput, + imports, + fileInfo, + parseNode, + /** + * Parse an input string into an abstract syntax tree, + * @param {string} str A string containing 'less' markup + * @param {Function} callback call `callback` when done. + * @param {*} [additionalData] An optional map which can contains vars - a map (key, value) of variables to apply + */ + parse: function (str, callback, additionalData) { + /** @type {any} */ + let root; + /** @type {null} */ + let err = null; + let globalVars; + let modifyVars; + let ignored; + let preText = ''; - let body; - parserInput.save(); - if (!parserInput.$str('(')) { - parserInput.restore(); - return ; - } - body = tryConditionFollowedByParenthesis(this); - if (body) { - parserInput.forget(); - return body; - } + // Optionally disable @plugin parsing + if (additionalData && additionalData.disablePluginRule) { + parsers.override('plugin', function() { + var dir = parserInput.$re(/^@plugin?\s+/); + if (dir) { + error('@plugin statements are not allowed when disablePluginRule is set to true'); + } + return undefined + }) + } - body = this.atomicCondition(needsParens); - if (!body) { - parserInput.restore(); - return ; - } - if (!parserInput.$char(')')) { - parserInput.restore(`expected ')' got '${parserInput.currentChar()}'`); - return ; - } - parserInput.forget(); - return body; - }, - atomicCondition: function (needsParens, preparsedCond) { - const entities = this.entities; - const index = parserInput.i; - let a; - let b; - let c; - let op; - - const cond = (function() { - return this.addition() || entities.keyword() || entities.quoted() || entities.mixinLookup(); - }).bind(this) - - if (preparsedCond) { - a = preparsedCond; - } else { - a = cond(); - } + globalVars = (additionalData && additionalData.globalVars) ? `${Parser.serializeVars(additionalData.globalVars)}\n` : ''; + modifyVars = (additionalData && additionalData.modifyVars) ? `\n${Parser.serializeVars(additionalData.modifyVars)}` : ''; - if (a) { - if (parserInput.$char('>')) { - if (parserInput.$char('=')) { - op = '>='; - } else { - op = '>'; - } - } else - if (parserInput.$char('<')) { - if (parserInput.$char('=')) { - op = '<='; - } else { - op = '<'; - } - } else - if (parserInput.$char('=')) { - if (parserInput.$char('>')) { - op = '=>'; - } else if (parserInput.$char('<')) { - op = '=<'; - } else { - op = '='; - } - } - if (op) { - b = cond(); - if (b) { - c = new(tree.Condition)(op, a, b, index + currentIndex, false); - } else { - error('expected expression'); - } - } else if (!preparsedCond) { - c = new(tree.Condition)('=', a, new(tree.Keyword)('true'), index + currentIndex, false); - } - return c; + if (context.pluginManager) { + let preProcessors = context.pluginManager.getPreProcessors(); + for (let i = 0; i < preProcessors.length; i++) { + str = preProcessors[i].process(str, { context, imports, fileInfo }); } - }, + } - // - // An operand is anything that can be part of an operation, - // such as a Color, or a Variable - // - operand: function () { - const entities = this.entities; - let negate; + if (globalVars || (additionalData && additionalData.banner)) { + preText = ((additionalData && additionalData.banner) ? additionalData.banner : '') + globalVars; + ignored = imports.contentsIgnoredChars; + ignored[fileInfo.filename] = ignored[fileInfo.filename] || 0; + ignored[fileInfo.filename] += preText.length; + } - if (parserInput.peek(/^-[@$(]/)) { - negate = parserInput.$char('-'); - } + str = str.replace(/\r\n?/g, '\n'); + // Remove potential UTF Byte Order Mark + str = preText + str.replace(/^\uFEFF/, '') + modifyVars; + imports.contents[fileInfo.filename] = str; - let o = this.sub() || entities.dimension() || - entities.color() || entities.variable() || - entities.property() || entities.call() || - entities.quoted(true) || entities.colorKeyword() || - entities.mixinLookup(); + // Start with the primary rule. + // The whole syntax tree is held under a Ruleset node, + // with the `root` property set to true, so no `{}` are + // output. The callback is called when the input is parsed. + try { + parserInput.start(str, context.chunkInput, function fail(msg, index) { + throw new LessError({ + index, + type: 'Parse', + message: msg, + filename: fileInfo.filename + }, imports); + }); - if (negate) { - o.parensInOp = true; - o = new(tree.Negative)(o); - } + /** @type {*} */ (Node.prototype).parse = this; + root = new Ruleset(null, this.parsers.primary()); + /** @type {*} */ (Node.prototype).rootNode = root; + root.root = true; + root.firstRoot = true; + root.functionRegistry = functionRegistry.inherit(); - return o; - }, + } catch (e) { + return callback(new LessError(e, imports, fileInfo.filename)); + } + // If `i` is smaller than the `input.length - 1`, + // it means the parser wasn't able to parse the whole + // string, so we've got a parsing error. // - // Expressions either represent mathematical operations, - // or white-space delimited Entities. - // - // 1px solid black - // @var * 2 - // - expression: function () { - const entities = []; - let e; - let delim; - const index = parserInput.i; - - do { - e = this.comment(); - if (e) { - entities.push(e); - continue; - } - e = this.addition() || this.entity(); + // We try to extract a \n delimited string, + // showing the line where the parse error occurred. + // We split it up into two parts (the part which parsed, + // and the part which didn't), so we can color them differently. + const endInfo = parserInput.end(); + if (!endInfo.isFinished) { - if (e instanceof tree.Comment) { - e = null; - } + let message = endInfo.furthestPossibleErrorMessage; - if (e) { - entities.push(e); - // operations do not allow keyword "/" dimension (e.g. small/20px) so we support that here - if (!parserInput.peek(/^\/[/*]/)) { - delim = parserInput.$char('/'); - if (delim) { - entities.push(new(tree.Anonymous)(delim, index + currentIndex)); - } - } + if (!message) { + message = 'Unrecognised input'; + if (endInfo.furthestChar === '}') { + message += '. Possibly missing opening \'{\''; + } else if (endInfo.furthestChar === ')') { + message += '. Possibly missing opening \'(\''; + } else if (endInfo.furthestReachedEnd) { + message += '. Possibly missing something'; } - } while (e); - if (entities.length > 0) { - return new(tree.Expression)(entities); } - }, - property: function () { - const name = parserInput.$re(/^(\*?-?[_a-zA-Z0-9-]+)\s*:/); - if (name) { - return name[1]; - } - }, - ruleProperty: function () { - let name = []; - const index = []; - let s; - let k; - parserInput.save(); + err = new LessError({ + type: 'Parse', + message, + index: endInfo.furthest, + filename: fileInfo.filename + }, imports); + } - const simpleProperty = parserInput.$re(/^([_a-zA-Z0-9-]+)\s*:/); - if (simpleProperty) { - name = [new(tree.Keyword)(simpleProperty[1])]; - parserInput.forget(); - return name; - } + /** + * @param {Error | LessError} [e] + */ + const finish = e => { + e = err || e || imports.error; - function match(re) { - const i = parserInput.i; - const chunk = parserInput.$re(re); - if (chunk) { - index.push(i); - return name.push(chunk[1]); + if (e) { + if (!(e instanceof LessError)) { + e = new LessError(e, imports, fileInfo.filename); } - } - match(/^(\*?)/); - while (true) { - if (!match(/^((?:[\w-]+)|(?:[@$]\{[\w-]+\}))/)) { - break; - } + return callback(e); } - - if ((name.length > 1) && match(/^((?:\+_|\+)?)\s*:/)) { - parserInput.forget(); - - // at last, we have the complete match now. move forward, - // convert name particles to tree objects and return: - if (name[0] === '') { - name.shift(); - index.shift(); - } - for (k = 0; k < name.length; k++) { - s = name[k]; - name[k] = (s.charAt(0) !== '@' && s.charAt(0) !== '$') ? - new(tree.Keyword)(s) : - (s.charAt(0) === '@' ? - new(tree.Variable)(`@${s.slice(2, -1)}`, index[k] + currentIndex, fileInfo) : - new(tree.Property)(`$${s.slice(2, -1)}`, index[k] + currentIndex, fileInfo)); - } - return name; + else { + return callback(null, root); } - parserInput.restore(); + }; + + if (context.processImports !== false) { + new visitors.ImportVisitor(imports, finish) + .run(root); + } else { + return finish(); } - } + }, + + // + // Here in, the parsing rules/functions + // + // The basic structure of the syntax tree generated is as follows: + // + // Ruleset -> Declaration -> Value -> Expression -> Entity + // + // Here's some Less code: + // + // .class { + // color: #fff; + // border: 1px solid #000; + // width: @w + 4px; + // > .child {...} + // } + // + // And here's what the parse tree might look like: + // + // Ruleset (Selector '.class', [ + // Declaration ("color", Value ([Expression [Color #fff]])) + // Declaration ("border", Value ([Expression [Dimension 1px][Keyword "solid"][Color #000]])) + // Declaration ("width", Value ([Expression [Operation " + " [Variable "@w"][Dimension 4px]]])) + // Ruleset (Selector [Element '>', '.child'], [...]) + // ]) + // + // In general, most rules will try to parse a token with the `$re()` function, and if the return + // value is truly, will return a new node, of the relevant type. Sometimes, we need to check + // first, before parsing, that's when we use `peek()`. + // + parsers }; }; +/** + * @param {Record} vars + */ Parser.serializeVars = vars => { let s = ''; diff --git a/packages/less/src/less/tree/expression.js b/packages/less/src/less/tree/expression.js index 03b40a1c87..a7d5a1a694 100644 --- a/packages/less/src/less/tree/expression.js +++ b/packages/less/src/less/tree/expression.js @@ -7,6 +7,7 @@ import Anonymous from './anonymous'; const Expression = function(value, noSpacing) { this.value = value; this.noSpacing = noSpacing; + this.parens = false; if (!value) { throw new Error('Expression requires an array parameter'); } diff --git a/packages/less/src/less/tree/ruleset.js b/packages/less/src/less/tree/ruleset.js index a3324cf076..79e9c8b7ea 100644 --- a/packages/less/src/less/tree/ruleset.js +++ b/packages/less/src/less/tree/ruleset.js @@ -294,13 +294,11 @@ Ruleset.prototype = Object.assign(new Node(), { // guard against root being a string (in the case of inlined less) if (r.type === 'Import' && r.root && r.root.variables) { const vars = r.root.variables(); - for (const name in vars) { - // eslint-disable-next-line no-prototype-builtins - if (vars.hasOwnProperty(name)) { - hash[name] = r.root.variable(name); - } + for (const [name, variable] of Object.entries(vars)) { + hash[name] = variable; } } + return hash; }, {}); } diff --git a/packages/less/tsconfig.json b/packages/less/tsconfig.json index f3ededb691..0ff9065933 100644 --- a/packages/less/tsconfig.json +++ b/packages/less/tsconfig.json @@ -8,6 +8,8 @@ "esModuleInterop": true, "importHelpers": true, "noImplicitAny": true, + "noImplicitThis": true, + "strictNullChecks": true, "target": "ES5" }, "ts-node": { diff --git a/packages/test-data/css/_main/layer.css b/packages/test-data/css/_main/layer.css new file mode 100644 index 0000000000..31f3c9ad6c --- /dev/null +++ b/packages/test-data/css/_main/layer.css @@ -0,0 +1,15 @@ +@layer { + .main::before { + color: #f00; + } +} +@layer legacy { + .sub-rule ul { + color: white; + } +} +@layer primevue { + .test { + foo: bar; + } +} diff --git a/packages/test-data/errors/parse/parse-error-media-no-block-1.txt b/packages/test-data/errors/parse/parse-error-media-no-block-1.txt index 3af15b70c0..9f20b76882 100644 --- a/packages/test-data/errors/parse/parse-error-media-no-block-1.txt +++ b/packages/test-data/errors/parse/parse-error-media-no-block-1.txt @@ -1,3 +1,3 @@ -SyntaxError: media definitions require block statements after any features in {path}parse-error-media-no-block-1.less on line 1, column 24: +SyntaxError: nested at-rules require block statements after any features in {path}parse-error-media-no-block-1.less on line 1, column 24: 1 @media (extra: bracket)) { 2 body { diff --git a/packages/test-data/errors/parse/parse-error-media-no-block-2.txt b/packages/test-data/errors/parse/parse-error-media-no-block-2.txt index 6d774ea742..a49b8b8c9e 100644 --- a/packages/test-data/errors/parse/parse-error-media-no-block-2.txt +++ b/packages/test-data/errors/parse/parse-error-media-no-block-2.txt @@ -1,2 +1,2 @@ -SyntaxError: media definitions require block statements after any features in {path}parse-error-media-no-block-2.less on line 1, column 7: +SyntaxError: nested at-rules require block statements after any features in {path}parse-error-media-no-block-2.less on line 1, column 7: 1 @media diff --git a/packages/test-data/errors/parse/parse-error-media-no-block-3.txt b/packages/test-data/errors/parse/parse-error-media-no-block-3.txt index 7bb0f4c190..fd3c8ed9a4 100644 --- a/packages/test-data/errors/parse/parse-error-media-no-block-3.txt +++ b/packages/test-data/errors/parse/parse-error-media-no-block-3.txt @@ -1,3 +1,3 @@ -SyntaxError: media definitions require block statements after any features in {path}parse-error-media-no-block-3.less on line 4, column 4: +SyntaxError: nested at-rules require block statements after any features in {path}parse-error-media-no-block-3.less on line 4, column 4: 3 font-size: 5000px; 4 } diff --git a/packages/test-data/less/_main/import/layer-import.less b/packages/test-data/less/_main/import/layer-import.less new file mode 100644 index 0000000000..0b24cd173d --- /dev/null +++ b/packages/test-data/less/_main/import/layer-import.less @@ -0,0 +1,5 @@ +.sub-rule { + ul { + color: white; + } +} \ No newline at end of file diff --git a/packages/test-data/less/_main/layer.less b/packages/test-data/less/_main/layer.less new file mode 100644 index 0000000000..bc394ac7e4 --- /dev/null +++ b/packages/test-data/less/_main/layer.less @@ -0,0 +1,18 @@ +// see: https://github.com/less/less.js/issues/4242 +.main{ + @layer{ + &::before{ + color:#f00; + } + } +} + +@layer legacy { + @import "./import/layer-import.less"; +} + +@layer-name: primevue; + +@layer @layer-name { + .test { foo: bar; } +} \ No newline at end of file