'use strict';
/* eslint-disable class-methods-use-this */

(() => {
  //#region Types

  const parserlib = typeof self !== 'undefined'
    ? (require('/js/csslint/parserlib-base'), self.parserlib)
    : require('./parserlib-base');
  const {assign, defineProperty: define} = Object;
  const {
    css: {
      Combinators,
      NamedColors,
      Tokens,
      Units,
    },
    util: {
      Bucket,
      EventTarget,
      StringSource,
      TokenIdByCode,
      UnitTypeIds,
      clipString,
      isOwn,
      pick,
      validateProperty,
    },
  } = parserlib;
  const {
    AMP, AT, CHAR, COLON, COMMA, COMMENT, DELIM, DOT, HASH, FUNCTION,
    IDENT, LBRACE, LBRACKET, LPAREN, MINUS, NUMBER, PCT, PIPE, PLUS,
    RBRACE, RBRACKET, RPAREN, SEMICOLON, STAR, UVAR, WS,
  } = Tokens;
  const TT = {
    attrEq: [Tokens.ATTR_EQ, Tokens.EQUALS],
    attrEqEnd: [Tokens.ATTR_EQ, Tokens.EQUALS, RBRACKET],
    attrStart: [PIPE, IDENT, STAR],
    attrNameEnd: [RBRACKET, UVAR, WS],
    colonLParen: [COLON, LPAREN],
    combinator: [PLUS, Tokens.GT, Tokens.COMBINATOR],
    condition: [FUNCTION, IDENT, LPAREN],
    declEnd: [SEMICOLON, RBRACE],
    docFunc: [FUNCTION, IDENT/* while typing a new func */, Tokens.URI],
    identStar: [IDENT, STAR],
    identString: [IDENT, Tokens.STRING],
    mediaList: [IDENT, LPAREN],
    mediaValue: [IDENT, NUMBER, Tokens.DIMENSION, Tokens.LENGTH],
    propCustomEnd: [DELIM, SEMICOLON, RBRACE, RBRACKET, RPAREN, Tokens.INVALID],
    propValEnd: [DELIM, SEMICOLON, RBRACE],
    propValEndParen: [DELIM, SEMICOLON, RBRACE, RPAREN],
    pseudo: [FUNCTION, IDENT],
    selectorStart: [AMP, PIPE, IDENT, STAR, HASH, DOT, LBRACKET, COLON],
    stringUri: [Tokens.STRING, Tokens.URI],
  };
  const B = /** @type {{[key:string]: Bucket}} */ {
    attrIS: ['i', 's', ']'], // "]" is to improve the error message,
    colors: NamedColors,
    marginSyms: (map => 'B-X,B-L-C,B-L,B-R-C,B-R,L-B,L-M,L-T,R-B,R-M,R-T,T-X,T-L-C,T-L,T-R-C,T-R'
      .replace(/[A-Z]/g, s => map[s]).split(',')
    )({B: 'bottom', C: 'corner', L: 'left', M: 'middle', R: 'right', T: 'top', X: 'center'}),
  };
  for (const k in B) B[k] = new Bucket(B[k]);
  for (const k of 'and,andOr,auto,autoNone,evenOdd,fromTo,important,layer,n,none,not,notOnly,of,or'
    .split(',')) B[k] = new Bucket(k.split(/(?=[A-Z])/)); // splitting by an Uppercase A-Z letter
  const OrDie = {must: true};
  const OrDieReusing = {must: true, reuse: true};
  const Parens = []; Parens[LBRACE] = RBRACE; Parens[LBRACKET] = RBRACKET; Parens[LPAREN] = RPAREN;
  const PDESC = {configurable: true, enumerable: true, writable: true, value: null};
  /** For these tokens stream.match() will return a UVAR unless the next token is a direct match */
  const UVAR_PROXY = [PCT, ...TT.mediaValue, ...TT.identString]
    .reduce((res, id) => (res[id] = true) && res, []);
  // Sticky `y` flag must be used in expressions for StringSource's readMatch
  // Groups must be non-capturing (?:foo) unless explicitly necessary
  const rxCommentUso = /(\*)\[\[[-\w]+]]\*\/|\*(?:[^*]+|\*(?!\/))*(?:\*\/|$)/y;
  const rxDigits = /\d+/y;
  const rxMaybeQuote = /\s*['"]?/y;
  const rxName = /(?:[-_\da-zA-Z\u00A0-\uFFFF]+|\\(?:(?:[0-9a-fA-F]{1,6}|.)[\t ]?|$))+/y;
  const rxNth = /(even|odd)|(?:([-+]?\d*n)(?:\s*([-+])(\s*\d+)?)?|[-+]?\d+)((?=\s+of\s+|\s*\)))?/yi;
  const rxNumberDigit = /\d*(?:(\.)\d*)?(?:(e)[+-]?\d+)?/iy;
  const rxNumberDot = /\d+(?:(e)[+-]?\d+)?/iy;
  const rxNumberSign = /(?:(\.)\d+|\d+(?:(\.)\d*)?)(?:(e)[+-]?\d+)?/iy;
  const rxSign = /[-+]/y;
  const rxSpace = /\s+/y;
  const rxSpaceCmtRParen = /(?=\s|\/\*|\))/y;
  const rxSpaceComments = /(?:\s+|\/\*(?:[^*]+|\*(?!\/))*(?:\*\/|$))+/y;
  const rxSpaceRParen = /\s*\)/y;
  const rxStringDoubleQ = /(?:[^\n\\"]+|\\(?:([0-9a-fA-F]{1,6}|.)[\t ]?|\n|$))*/y;
  const rxStringSingleQ = /(?:[^\n\\']+|\\(?:([0-9a-fA-F]{1,6}|.)[\t ]?|\n|$))*/y;
  const rxUnescapeLF = /\\(?:(?:([0-9a-fA-F]{1,6})|(.))[\t ]?|(\n))/g;
  const rxUnescapeNoLF = /\\(?:([0-9a-fA-F]{1,6})|(.))[\t ]?/g;
  const rxUnicodeRange = /\+([\da-f]{1,6})(\?{1,6}|-([\da-f]{1,6}))?/iy; // U was already consumed
  const rxUnquotedUrl = /(?:[-!#$%&*-[\]-~\u00A0-\uFFFF]+|\\(?:(?:[0-9a-fA-F]{1,6}|.)[\t ]?|$))+/y;
  const [rxDeclBlock, rxDeclValue] = ((
    exclude = String.raw`'"{}()[\]\\/`,
    orSlash = ']|/(?!\\*))',
    blk = String.raw`(?:"[^"\n\\]*"|[^${exclude}${orSlash}*`,
    common = `(?:${[
      rxUnescapeLF.source,
      `"${rxStringDoubleQ.source}"`,
      `'${rxStringSingleQ.source}'`,
      String.raw`\(${blk}\)|\[${blk}]`,
      String.raw`/\*(?:[^*]+|\*(?!\/))*(?:\*\/|$)`,
    ].join('|')}|`
  ) => [`{${blk}}|[^`, '[^;'].map(str => RegExp(common + str + exclude + orSlash + '+', 'y')))();
  const isRelativeSelector = sel => isOwn(TT.combinator, sel.parts[0].id);
  const isIdentChar = (c, prev) => c >= 97 && c <= 122 || c >= 65 && c <= 90 /* a-z A-Z */ ||
    c === 45 || c === 92 || c === 95 || c >= 160 || c >= 48 && c <= 57 /* - \ _ 0-9 */ ||
    prev === 92 /* \ */ && c !== 10 && c != null;
  const isIdentStart = (a, b) => a >= 97 && a <= 122 || a >= 65 && a <= 90 /* a-z A-Z */ ||
    a === 95 || a >= 160 || // _ unicode
    (a === 45/*-*/ ? b !== 45 && isIdentStart(b) : a === 92/*\*/ && isIdentChar(b, a));
  const isSpace = c => c === 9 && c === 10 || c === 32;
  const textToTokenMap = obj => Object.keys(obj).reduce((res, k) =>
    (((res[TokenIdByCode[k.charCodeAt(0)]] = obj[k]), res)), []);
  const toLowAscii = c => c >= 65 && c <= 90 ? c + 32 : c;
  const toStringPropHack = function () { return this.hack + this.text; };
  const unescapeNoLF = (m, code, char) => char || String.fromCodePoint(parseInt(code, 16));
  const unescapeLF = (m, code, char, LF) => LF ? '' : char ||
    String.fromCodePoint(parseInt(code, 16));
  const parseString = str => str.slice(1, -1).replace(rxUnescapeLF, unescapeLF);
  /**
   * @property {boolean} isUvp
   * @typedef {true[]} TokenMap - index is a token id
   */
  TT.nestSel = [...TT.selectorStart, ...TT.combinator];
  for (const k in TT) {
    TT[k] = TT[k].reduce((res, id) => {
      if (UVAR_PROXY[id]) res.isUvp = 1;
      res[id] = true;
      return res;
    }, []);
  }
  delete TT.nestSel[IDENT];

  //#endregion
  //#region Syntax units

  /**
   * @property {[]} [args] added in selectors
   * @property {string} [atName] lowercase name of @-rule without -vendor- prefix
   * @property {TokenValue} [expr] body of function or block
   * @property {boolean} [ie] ie function
   * @property {boolean} [is0] number is an integer 0 without units
   * @property {boolean} [isAttr] = attr()
   * @property {boolean} [isAuto] = auto
   * @property {boolean} [isCalc] = calc()
   * @property {boolean} [isInt] = integer without units
   * @property {boolean} [isNone] = none
   * @property {boolean} [isVar] = var(), env(), /*[[var]]* /
   * @property {'*'|'_'} [hack] for property name in IE mode
   * @property {string} [lowText] text.toLowerCase() added on demand
   * @property {string} [name] name of function
   * @property {number} [number] parsed number
   * @property {string} [prefix] lowercase `-vendor-` prefix
   * @property {string} [units] lowercase units of a number
   * @property {string} [uri] parsed uri string
   * @property {number} [vendorCode] char code of vendor name i.e. 102 for "f" in -moz-foo
   * @property {number} [vendorPos] position of vendor name i.e. 5 for "f" in -moz-foo
   */
  class Token {
    constructor(id, col, line, offset, input, code) {
      this.id = id;
      this.col = col;
      this.line = line;
      this.offset = offset;
      this.offset2 = offset + 1;
      this.type = '';
      this.code = toLowAscii(code);
      this._input = input;
    }
    /** @return {Token} */
    static from(tok) {
      return assign(Object.create(this.prototype), tok);
    }
    get length() {
      return isOwn(this, 'text') ? this.text.length : this.offset2 - this.offset;
    }
    get string() {
      const str = PDESC.value = parseString(this.text);
      define(this, 'string', PDESC);
      return str;
    }
    set string(val) {
      PDESC.value = val;
      define(this, 'string', PDESC);
    }
    get text() {
      return this._input.slice(this.offset, this.offset2);
    }
    set text(val) {
      PDESC.value = val;
      define(this, 'text', PDESC);
    }
    valueOf() {
      return this.text;
    }
    toString() {
      return this.text;
    }
  }

  class TokenFunc extends Token {
    /** @return {TokenFunc} */
    static from(tok, expr, end) {
      tok = super.from(tok);
      tok.type = 'fn';
      if (isOwn(tok, 'text')) tok.offsetBody = tok.offset2;
      if (end) tok.offset2 = end.offset2;
      if (expr) {
        tok.expr = expr;
        let n = tok.name; // these functions are valid only if not empty
        if (n === 'calc' || n === 'clamp' || n === 'min' || n === 'max' ||
            n === 'sin' || n === 'cos' || n === 'tan' || n === 'asin' ||
            n === 'acos' || n === 'atan' || n === 'atan2') {
          tok.isCalc = true;
        } else if (n === 'var' || n === 'env') {
          tok.isVar = true;
        } else if (n === 'attr' && (n = expr.parts[0]) && (n.id === IDENT || n.id === UVAR)) {
          tok.isAttr = true;
        }
      }
      return tok;
    }
    toString() { // FIXME: account for escapes
      const s = this._input;
      return isOwn(this, 'text')
        ? this.text + s.slice(this.offsetBody + 1, this.offset2)
        : s.slice(this.offset, this.offset2);
    }
  }

  class TokenValue extends Token {
    /** @return {TokenValue} */
    static from(parts, tok = parts[0]) {
      tok = super.from(tok);
      tok.parts = parts;
      return tok;
    }
    /** @return {TokenValue} */
    static empty(tok) {
      tok = super.from(tok);
      tok.parts = [];
      tok.id = WS;
      tok.offset2 = tok.offset;
      delete tok.text;
      return tok;
    }
    get text() { // FIXME: account for escapes
      return this._input.slice(this.offset, (this.parts[this.parts.length - 1] || this).offset2);
    }
    set text(val) {
      PDESC.value = val;
      define(this, 'text', PDESC);
    }
  }

  class SyntaxError extends Error {
    constructor(message, pos) {
      super();
      this.name = this.constructor.name;
      this.col = pos.col;
      this.line = pos.line;
      this.offset = pos.offset;
      this.message = message;
    }
  }

  //#endregion
  //#region TokenStream

  /**
   * @property {()=>Token|false} grab - gets the next token skipping WS and UVAR
   */
  class TokenStream {

    constructor(input) {
      this.source = new StringSource(input ? `${input}` : '');
      /** Number of consumed "&" tokens */
      this._amp = 0;
      /** Lookahead buffer size */
      this._max = 4;
      this._resetBuf();
      define(this, 'grab', {writable: true, value: this.get.bind(this, true)});
    }

    _resetBuf() {
      /** @type {Token} Last consumed token object */
      this.token = null;
      /** Lookahead token buffer */
      this._buf = [];
      /** Current index may change due to unget() */
      this._cur = 0;
      /** Wrapping around the index to avoid splicing/shifting the array */
      this._cycle = 0;
    }

    /**
     * @param {TokenAcquisitionMode} [mode]
     * @return {Token}
     */
    get(mode) {
      let {_buf: buf, _cur: i, _max: MAX} = this;
      let tok, ti, slot;
      do {
        slot = (i + this._cycle) % MAX;
        if (i >= buf.length) {
          if (buf.length < MAX) i++;
          else this._cycle = (this._cycle + 1) % MAX;
          ti = (tok = buf[slot] = this._getToken(mode)).id;
          break;
        }
        ++i;
        ti = (tok = buf[slot]).id;
      } while (ti === COMMENT || mode && (ti === WS || ti === UVAR && mode !== UVAR));
      if (ti === AMP) this._amp++;
      this._cur = i;
      this.token = tok;
      return tok;
      /**
       * @typedef {number} TokenAcquisitionMode
       * 0/falsy: skip COMMENT;
       * UVAR id: skip COMMENT, WS;
       * anything else: skip COMMENT, WS, UVAR
       */
    }

    /**
     * Consumes the next token if it matches the condition(s).
     * @param {Token|TokenMap} what
     * @param {Bucket} [text]
     * @param {Token} [tok]
     * @param {{must?: boolean}} [opts]
     * @return {Token|false}
     */
    match(what, text, tok = this.get(), opts) {
      if ((typeof what === 'object' ? isOwn(what, tok.id) : !what || tok.id === what) &&
          (!text || text.has(tok))) {
        return tok;
      }
      if (opts !== UVAR) {
        this.unget();
        if (opts && opts.must) this._failure(text || what, tok);
        return false;
      }
    }

    /** @return {Token|false} */
    matchOrDie(what, text, tok) {
      return this.match(what, text, tok, OrDie);
    }

    /**
     * Skips whitespace and consumes the next token if it matches the condition(s).
     * @param {Token|TokenMap} what
     * @param {{}|Bucket} [opts]
     * @param {Token|boolean} [opts.reuse]
     * @param {boolean} [opts.must]
     * @param {Bucket} [opts.text]
     * @return {Token|false}
     */
    matchSmart(what, opts = {}) {
      let tok;
      const text = opts.has ? opts : (tok = opts.reuse, opts.text);
      const ws = typeof what === 'object' ? what[WS] : what === WS;
      let uvp = !ws && !text && (typeof what === 'object' ? what.isUvp : isOwn(UVAR_PROXY, what));
      tok = tok && (tok.id != null ? tok : this.token) || this.get(uvp ? UVAR : !ws);
      uvp = uvp && tok.isVar;
      return this.match(what, text, tok, uvp ? UVAR : opts) ||
        uvp && (this.match(what, text, this.grab()) || tok) ||
        false;
    }

    /** @return {Token} */
    peekCached() {
      return this._cur < this._buf.length && this._buf[(this._cur + this._cycle) % this._max];
    }

    /** Restores the last consumed token to the token stream. */
    unget() {
      if (this._cur) {
        if ((this.token || {}).id === AMP) this._amp--;
        this.token = this._buf[(--this._cur - 1 + this._cycle + this._max) % this._max];
      } else {
        throw new Error('Too much lookahead.');
      }
    }

    _failure(goal = '', tok = this.token, throwIt = true) {
      goal = typeof goal === 'string' ? goal :
        goal instanceof Bucket ? `"${goal.join('", "')}"` :
          (+goal ? [goal] : goal).reduce((res, v, id) => res + (res ? ', ' : '') +
            ((v = Tokens[v === true ? id : v]).text ? `"${v.text}"` : v.name), '');
      goal = goal ? `Expected ${goal} but found` : 'Unexpected';
      goal = new SyntaxError(`${goal} "${clipString(tok)}".`, tok);
      if (throwIt) throw goal;
      return goal;
    }

    /**
     * @param {TokenAcquisitionMode} mode
     * @return {Token|void}
     */
    _getToken(mode) {
      const src = this.source;
      let a, b, c, text, col, line, offset;
      while (true) {
        ({col, line, offset} = src);
        a = src.readCode(); if (a == null) break;
        b = src.peek();
        if (a === 9/*\t*/ || a === 10/*\n*/ || a === 32/* " " */) {
          if (isSpace(b)) src.readMatch(rxSpace);
          if (!mode) { c = WS; break; }
        } else if (a === 47/*/*/ && b === 42/* * */) {
          a = src.readMatch(rxCommentUso, true);
          if (a[1] && mode === UVAR) { c = UVAR; break; }
        } else break;
      }
      const tok = new Token(c || CHAR, col, line, offset, src.string, a);
      if (c) {
        if (c === UVAR) tok.isVar = true;
      // [0-9]
      } else if (a >= 48 && a <= 57) {
        c = b >= 48 && b <= 57 || b === 46/*.*/ ||
          (b === 69 || b === 101) && (c = src.peek(2)) === 43 || c === 45 || c >= 48 && c <= 57;
        text = this._number(src, tok, a, b, c, rxNumberDigit);
      // [-+.]
      } else if ((a === 45 || a === 43 && (tok.id = PLUS) || a === 46 && (tok.id = DOT)) && (
      /* [-+.][0-9] */ b >= 48 && b <= 57 ||
      /* [-+].[0-9] */ b === 46/*.*/ && a !== 46 && (c = src.peek(2)) >= 48 && c <= 57
      )) {
        text = this._number(src, tok, a, b, 1, a === 46 ? rxNumberDot : rxNumberSign);
      // -
      } else if (a === 45) {
        if (b === 45/* -- */) {
          if (isIdentChar(c || (c = src.peek(2)), b)) {
            text = this._ident(src, tok, a, b, 1, c, 1);
            tok.type = 'custom-prop';
          } else if (c === 62/* --> */) {
            src.read(2, '->');
            tok.id = Tokens.CDCO;
          } else {
            tok.id = MINUS;
          }
        } else if (isIdentStart(b, b === 92/*\*/ && (c || (c = src.peek(2))))) {
          text = this._ident(src, tok, a, b, 1, c);
        } else {
          tok.id = MINUS;
        }
      // U+ u+
      } else if ((a === 85 || a === 117) && b === 43) {
        c = src.readMatch(rxUnicodeRange, true);
        if (c && parseInt(c[1], 16) <= 0x10FFFF && (
          c[3] ? parseInt(c[3], 16) <= 0x10FFFF
            : !c[2] || (c[1] + c[2]).length <= 6
        )) {
          tok.id = Tokens.URANGE;
        } else {
          if (c) { src.col -= (c = c[0].length); src.offset -= c; }
          tok.id = IDENT;
          tok.type = 'ident';
        }
      // a-z A-Z \ _ unicode ("-" was handled above)
      } else if (isIdentStart(a, b)) {
        text = this._ident(src, tok, a, b);
      } else if ((c = b === 61 // =
      /* $= *= ^= |= ~= */
        ? (a === 36 || a === 42 || a === 94 || a === 124 || a === 126) &&
          Tokens.ATTR_EQ
      /* <= >= */
        || (a === 60 || a === 62) && Tokens.EQ_CMP
      /* || */
        : a === 124 && b === 124 &&
          Tokens.COMBINATOR
      )) {
        tok.id = c;
        src.readCode();
      // #
      } else if (a === 35) {
        if (isIdentChar(b, a)) {
          text = this._ident(src, tok, a, b, 1);
          tok.id = HASH;
        }
      // *
      } else if (a === 42) {
        tok.id = STAR;
        if (isIdentStart(b)) tok.hack = '*';
      // [.,:;>+~=|*{}[]()]
      } else if ((c = TokenIdByCode[a])) {
        tok.id = c;
      // ["']
      } else if (a === 34 || a === 39) {
        src.readMatch(a === 34 ? rxStringDoubleQ : rxStringSingleQ);
        if (src.readMatchCode(a)) {
          tok.id = Tokens.STRING;
          tok.type = 'string';
        } else {
          tok.id = Tokens.INVALID;
        }
      // \
      } else if (a === 92) {
        if (b == null) text = '\uFFFD';
        else if (b === 10) { tok.id = WS; text = src.readMatch(rxSpace); }
      // @
      } else if (a === 64) {
        if (isIdentStart(b, c = (b === 45/*-*/ || b === 92/*\*/) && src.peek(2))) {
          c = this._ident(src, null, src.readCode(), c || src.peek());
          a = c.name;
          text = c.esc && `@${a}`;
          a = a.charCodeAt(0) === 45/*-*/ && (c = a.indexOf('-', 1)) > 1 ? a.slice(c + 1) : a;
          tok.atName = a.toLowerCase();
          tok.id = AT;
        }
      // <
      } else if (a === 60) {
        if (b === 33/*!*/ && src.readMatchStr('!--')) {
          tok.id = Tokens.CDCO;
        }
      } else if (a == null) {
        tok.id = Tokens.EOF;
      }
      if ((c = src.offset) !== offset + 1) tok.offset2 = c;
      if (text) { PDESC.value = text; define(tok, 'text', PDESC); }
      return tok;
    }

    _ident(src, tok, a, b,
      bYes = isIdentChar(b, a),
      c = bYes && this.source.peek(2),
      cYes = c && (isIdentChar(c, b) || a === 92 && isSpace(c))
    ) {
      const first = a === 92 ? (cYes = src.offset--, src.col--, '') : String.fromCharCode(a);
      const str = cYes ? src.readMatch(rxName) : bYes ? src.read() : '';
      const esc = a === 92 || b === 92 || bYes && c === 92 || str.length > 2 && str.includes('\\');
      const name = esc ? (first + str).replace(rxUnescapeNoLF, unescapeNoLF) : first + str;
      if (!tok) return {esc, name};
      if (a === 92) tok.code = toLowAscii(name.charCodeAt(0));
      const vp = a === 45/*-*/ && b !== 45 && name.indexOf('-', 2) + 1;
      const next = cYes || esc && isSpace(c) ? src.peek() : bYes ? c : b;
      let ovrValue = esc ? name : null;
      if (next === 40/*(*/) {
        src.read();
        c = name.toLowerCase();
        b = (c === 'url' || c === 'url-prefix' || c === 'domain') && this._uriValue(src);
        tok.id = b ? Tokens.URI : FUNCTION;
        tok.type = b ? 'uri' : 'fn';
        tok.name = vp ? c.slice(vp) : c;
        if (vp) tok.prefix = c.slice(0, vp);
        if (b) tok.uri = b;
      } else if (next === 58/*:*/ && name === 'progid') {
        ovrValue = name + src.readMatch(/.*?\(/y);
        tok.id = FUNCTION;
        tok.name = ovrValue.slice(0, -1).toLowerCase();
        tok.type = 'fn';
        tok.ie = true;
      } else {
        tok.id = IDENT;
        if (a === 45/*-*/ || (b = name.length) < 3 || b > 20) {
          tok.type = 'ident'; // named color min length is 3 (red), max is 20 (lightgoldenrodyellow)
        }
      }
      if (vp) {
        tok.vendorCode = toLowAscii(name.charCodeAt(vp));
        tok.vendorPos = vp;
      }
      return ovrValue;
    }

    _number(src, tok, a, b, bYes, rx) {
      const numStr = String.fromCharCode(a) + (bYes ? (b = src.readMatch(rx, true))[0] : '');
      const isFloat = a === 46/*.*/ || bYes && (b[1] || b[2] || b[3]);
      let ovrText, units;
      if ((a = bYes ? src.peek() : b) === 37) { // %
        tok.id = PCT;
        tok.type = units = src.read(1, '%');
      } else if (isIdentStart(a, b = (a === 45/*-*/ || a === 92/*\*/) && src.peek(2))) {
        a = this._ident(src, null, src.readCode(), b || src.peek());
        units = a.name;
        ovrText = a.esc && (numStr + units);
        a = Units[units = units.toLowerCase()] || '';
        tok.id = a && UnitTypeIds[a] || Tokens.DIMENSION;
        tok.type = a;
      } else {
        tok.id = NUMBER;
        tok.type = 'number';
      }
      tok.units = units || '';
      tok.number = a = +numStr;
      tok.is0 = b = !units && !a;
      tok.isInt = b || !units && !isFloat;
      return ovrText;
    }

    /** @param {StringSource} src */
    _spaceCmt(src) {
      const c = src.peek();
      return (c === 47/*/*/ || isSpace(c)) && src.readMatch(rxSpaceComments) || '';
    }

    /**
     * Consumes the closing ")" on success
     * @param {StringSource} [src]
     * @return {string|void}
     */
    _uriValue(src) {
      let v = src.peek();
      src.mark();
      v = v === 34/*"*/ || v === 39/*'*/ ? src.read()
        : isSpace(v) && src.readMatch(rxMaybeQuote).trim();
      if (v) {
        v += src.readMatch(v === '"' ? rxStringDoubleQ : rxStringSingleQ);
        v = src.readMatchStr(v[0]) && parseString(v + v[0]);
      } else if ((v = src.readMatch(rxUnquotedUrl)) && v.includes('\\')) {
        v = v.replace(rxUnescapeNoLF, unescapeNoLF);
      }
      if (v != null && (src.readMatchCode(41/*)*/) || src.readMatch(rxSpaceRParen))) {
        return v;
      }
      src.reset();
    }

    readNthChild() {
      const src = this.source;
      const m = (this._spaceCmt(src), src.readMatch(rxNth, true)); if (!m) return;
      let [, evenOdd, nth, sign, int, next] = m;
      let a, b, ws;
      if (evenOdd) a = evenOdd;
      else if (!(a = nth)) b = m[0]; // B
      else if ((sign || !next && (ws = this._spaceCmt(src), sign = src.readMatch(rxSign)))) {
        if (int || (src.mark(), this._spaceCmt(src), int = src.readMatch(rxDigits))) {
          b = sign + int.trim();
        } else return src.reset();
      }
      if ((a || b) && (ws || src.readMatch(rxSpaceCmtRParen) != null)) {
        return [a || '', b || ''];
      }
    }

    /**
     * @param {boolean} [inBlock] - to read to the end of the current {}-block
     */
    skipDeclBlock(inBlock) {
      for (let src = this.source, stack = [], end = inBlock ? 125 : -1, c; (c = src.peek());) {
        if (c === end || end < 0 && (c === 59/*;*/ || c === 125/*}*/)) {
          end = stack.pop();
          if (!end || end < 0 && c === 125/*}*/) {
            if (end || c === 59/*;*/) src.readCode(); // consuming ; or } of own block
            break;
          }
        } else if ((c = c === 123 ? 125/*{}*/ : c === 40 ? 41/*()*/ : c === 91 && 93/*[]*/)) {
          stack.push(end);
          end = c;
        }
        src.readCode();
        src.readMatch(end > 0 ? rxDeclBlock : rxDeclValue);
      }
      this._resetBuf();
    }
  }

  //#endregion
  //#region parserCache

  /**
   * Caches the results and reuses them on subsequent parsing of the same code
   */
  const parserCache = (() => {
    const MAX_DURATION = 10 * 60e3;
    const TRIM_DELAY = 10e3;
    // all blocks since page load; key = text between block start and { inclusive
    const data = new Map();
    // nested block stack
    const stack = [];
    // performance.now() of the current parser
    let generation = null;
    // performance.now() of the first parser after reset or page load,
    // used for weighted sorting in getBlock()
    let generationBase = null;
    let parser = null;
    let stream = null;

    return {
      start(newParser) {
        parser = newParser;
        if (!parser) {
          data.clear();
          stack.length = 0;
          generationBase = performance.now();
          return;
        }
        stream = parser.stream;
        generation = performance.now();
        trim();
      },
      addEvent(event) {
        if (!parser) return;
        for (let i = stack.length; --i >= 0;) {
          const {offset, offset2, events} = stack[i];
          if (event.offset >= offset && (!offset2 || event.offset <= offset2)) {
            events.push(event);
            return;
          }
        }
      },
      findBlock(token = getToken()) {
        if (!token || !stream) return;
        const src = stream.source;
        const {string} = src;
        const start = token.offset;
        const key = string.slice(start, string.indexOf('{', start) + 1);
        let block = data.get(key);
        if (!block || !(block = getBlock(block, string, start, key))) return;
        shiftBlock(block, start, token.line, token.col, string);
        src.offset = block.offset2;
        src.line = block.line2;
        src.col = block.col2;
        stream._resetBuf();
        return true;
      },
      startBlock(start = getToken()) {
        if (!start || !stream) return;
        stack.push({
          text: '',
          events: [],
          generation: generation,
          line: start.line,
          col: start.col,
          offset: start.offset,
          line2: undefined,
          col2: undefined,
          offset2: undefined,
        });
        return stack.length;
      },
      endBlock(end = getToken()) {
        if (!parser || !stream) return;
        const block = stack.pop();
        block.line2 = end.line;
        block.col2 = end.col + end.offset2 - end.offset;
        block.offset2 = end.offset2;
        const {string} = stream.source;
        const start = block.offset;
        const key = string.slice(start, string.indexOf('{', start) + 1);
        block.text = string.slice(start, block.offset2);
        let blocks = data.get(key);
        if (!blocks) data.set(key, (blocks = []));
        blocks.push(block);
      },
      cancelBlock: pos => pos === stack.length && stack.length--,
      feedback({messages}) {
        messages = new Set(messages);
        for (const blocks of data.values()) {
          for (const block of blocks) {
            if (!block.events.length) continue;
            if (block.generation !== generation) continue;
            const {line: L1, col: C1, line2: L2, col2: C2} = block;
            let isClean = true;
            for (const msg of messages) {
              const {line, col} = msg;
              if (L1 === L2 && line === L1 && C1 <= col && col <= C2 ||
                  line === L1 && col >= C1 ||
                  line === L2 && col <= C2 ||
                  line > L1 && line < L2) {
                messages.delete(msg);
                isClean = false;
              }
            }
            if (isClean) block.events.length = 0;
          }
        }
      },
    };

    /**
     * Removes old entries from the cache.
     * 'Old' means older than MAX_DURATION or half the blocks from the previous generation(s).
     * @param {Boolean} [immediately] - set internally when debounced by TRIM_DELAY
     */
    function trim(immediately) {
      if (!immediately) {
        clearTimeout(trim.timer);
        trim.timer = setTimeout(trim, TRIM_DELAY, true);
        return;
      }
      const cutoff = performance.now() - MAX_DURATION;
      for (const [key, blocks] of data.entries()) {
        const halfLen = blocks.length >> 1;
        const newBlocks = blocks
          .sort((a, b) => a.time - b.time)
          .filter((b, i) => (b = b.generation) > cutoff || b !== generation && i < halfLen);
        if (!newBlocks.length) {
          data.delete(key);
        } else if (newBlocks.length !== blocks.length) {
          data.set(key, newBlocks);
        }
      }
    }

    // gets the matching block
    function getBlock(blocks, input, start, key) {
      // extracted to prevent V8 deopt
      const keyLast = Math.max(key.length - 1);
      const check1 = input[start];
      const check2 = input[start + keyLast];
      const generationSpan = performance.now() - generationBase;
      blocks = blocks
        .filter(({text, offset, offset2}) =>
          text[0] === check1 &&
          text[keyLast] === check2 &&
          text[text.length - 1] === input[start + text.length - 1] &&
          text.startsWith(key) &&
          text === input.substr(start, offset2 - offset))
        .sort((a, b) =>
          // newest and closest will be the first element
          (a.generation - b.generation) / generationSpan +
          (Math.abs(a.offset - start) - Math.abs(b.offset - start)) / input.length);
      // identical blocks may produce different reports in CSSLint
      // so we need to either hijack an older generation block or make a clone
      const block = blocks.find(b => b.generation !== generation);
      return block || deepCopy(blocks[0]);
    }

    // Shifts positions of the block and its events, also fires the events
    function shiftBlock(block, cursor, line, col, input) {
      // extracted to prevent V8 deopt
      const deltaLines = line - block.line;
      const deltaCols = block.col === 1 && col === 1 ? 0 : col - block.col;
      const deltaOffs = cursor - block.offset;
      const hasDelta = deltaLines || deltaCols || deltaOffs;
      const shifted = new Set();
      for (const e of block.events) {
        if (hasDelta) {
          applyDelta(e, shifted, block.line, deltaLines, deltaCols, deltaOffs, input);
        }
        parser.fire(e, false);
      }
      block.generation = generation;
      block.col2 += block.line2 === block.line ? deltaCols : 0;
      block.line2 += deltaLines;
      block.offset2 = cursor + block.text.length;
      block.line += deltaLines;
      block.col += deltaCols;
      block.offset = cursor;
    }

    // Recursively applies the delta to the event and all its nested parts
    function applyDelta(obj, seen, line, lines, cols, offs, input) {
      if (seen.has(obj)) return;
      seen.add(obj);
      if (Array.isArray(obj)) {
        for (let i = 0, v; i < obj.length; i++) {
          if ((v = obj[i]) && typeof v === 'object') {
            applyDelta(v, seen, line, lines, cols, offs, input);
          }
        }
        return;
      }
      for (let i = 0, keys = Object.keys(obj), k, v; i < keys.length; i++) {
        k = keys[i];
        if (k === 'col' ? (cols && obj.line === line && (obj.col += cols), 0)
          : k === 'col2' ? (cols && obj.line2 === line && (obj.col2 += cols), 0)
          : k === 'line' ? (lines && (obj.line += lines), 0)
          : k === 'line2' ? (lines && (obj.line2 += lines), 0)
          : k === 'offset' ? (offs && (obj.offset += offs), 0)
          : k === 'offset2' ? (offs && (obj.offset2 += offs), 0)
          : k === '_input' ? (obj._input = input, 0)
          : k !== 'target' && (v = obj[k]) && typeof v === 'object'
        ) {
          applyDelta(v, seen, line, lines, cols, offs, input);
        }
      }
    }

    // returns next token if it's already seen or the current token
    function getToken() {
      return parser && (stream.peekCached() || stream.token);
    }

    function deepCopy(obj) {
      if (!obj || typeof obj !== 'object') {
        return obj;
      }
      if (Array.isArray(obj)) {
        return obj.map(deepCopy);
      }
      const copy = Object.create(Object.getPrototypeOf(obj));
      for (let arr = Object.keys(obj), k, v, i = 0; i < arr.length; i++) {
        v = obj[k = arr[i]];
        copy[k] = !v || typeof v !== 'object' ? v : deepCopy(v);
      }
      return copy;
    }
  })();

  //#endregion
  //#region Parser public API

  class Parser extends EventTarget {
    /**
     * @param {Object} [options]
     * @param {TokenStream} [options.stream]
     * @param {boolean} [options.ieFilters] - accepts IE < 8 filters instead of throwing
     * @param {boolean} [options.noValidation] - skip syntax validation
     * @param {boolean} [options.globalsOnly] - stop after all _fnGlobals()
     * @param {boolean} [options.starHack] - allows IE6 star hack
     * @param {boolean} [options.strict] - stop on errors instead of reporting them and continuing
     * @param {boolean} [options.topDocOnly] - quickly extract all top-level @-moz-document,
       their {}-block contents is retrieved as text using _simpleBlock()
     * @param {boolean} [options.underscoreHack] - interprets leading _ as IE6-7 for known props
     */
    constructor(options) {
      super();
      this.options = options || {};
      this.stream = null;
      /** @type {number} style rule nesting depth: when > 0 @nest and &-selectors are allowed */
      this._inStyle = 0;
      /** @type {Token[]} stack of currently processed nested blocks or rules */
      this._stack = [];
    }

    /** 2 and above = error, 2 = error (recoverable), 1 = warning, anything else = info */
    alarm(level, msg, token) {
      this.fire({
        type: level >= 2 ? 'error' : level === 1 ? 'warning' : 'info',
        message: msg,
        recoverable: level <= 2,
      }, token);
    }
    /**
     * @param {string|Object} e
     * @param {Token} [tok=this.stream.token] - sets the position
     */
    fire(e, tok = e.offset != null ? e : this.stream.token) {
      if (typeof e === 'string') e = {type: e};
      if (tok && e.offset == null) { e.offset = tok.offset; e.line = tok.line; e.col = tok.col; }
      if (tok !== false) parserCache.addEvent(e);
      super.fire(e);
    }

    parse(input, {reuseCache} = {}) {
      const stream = this.stream = new TokenStream(input);
      const opts = this.options;
      const atAny = !opts.globalsOnly && this._unknownAtRule;
      const atFuncs = !atAny ? Parser.GLOBALS : opts.topDocOnly ? Parser.AT_TDO : Parser.AT;
      parserCache.start(reuseCache && this);
      this.fire('startstylesheet');
      for (let ti, fn, tok; (ti = (tok = stream.grab()).id);) {
        try {
          if (ti === AT && (fn = atFuncs[tok.atName] || atAny)) {
            fn.call(this, stream, tok);
          } else if (ti === Tokens.CDCO) {
            // Skipping cruft
          } else if (!atAny) {
            stream.unget();
            break;
          } else if (!this._styleRule(stream, tok) && stream.grab().id) {
            stream._failure();
          }
        } catch (ex) {
          if (ex === Parser.GLOBALS) {
            break;
          }
          if (ex instanceof SyntaxError && !opts.strict) {
            this.fire(assign({}, ex, {type: 'error', error: ex}));
          } else {
            ex.message = (ti = ex.stack).includes(fn = ex.message) ? ti : `${fn}\n${ti}`;
            ex.line = tok.line;
            ex.col = tok.col;
            throw ex;
          }
        }
      }
      this.fire('endstylesheet');
    }

    //#endregion
    //#region Parser @-rules utilities

    /**
     * @param {TokenStream} stream
     * @param {Token} [tok]
     * @param {function} [fn]
     */
    _condition(stream, tok = stream.grab(), fn) {
      if (B.not.has(tok)) {
        this._conditionInParens(stream, undefined, fn);
      } else {
        let more;
        do { this._conditionInParens(stream, tok, fn); tok = undefined; }
        while ((more = stream.matchSmart(IDENT, !more ? B.andOr : B.or.has(more) ? B.or : B.and)));
      }
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} [tok]
     * @param {function} [fn]
     */
    _conditionInParens(stream, tok = stream.matchSmart(TT.condition), fn) {
      let x, reuse, paren;
      if (fn && fn.call(this, stream, tok)) {
        // NOP
      } else if (tok.name) {
        this._function(stream, tok);
        reuse = 0;
      } else if (tok.id === LPAREN && (paren = tok, tok = stream.matchSmart(TT.condition))) {
        if (fn && fn.call(this, stream, tok, paren)) {
          // NOP
        } else if (tok.id !== IDENT) {
          this._condition(stream, tok);
        } else if (B.not.has(tok)) {
          this._conditionInParens(stream);
        } else if ((x = stream.matchSmart(TT.colonLParen)).id === COLON) {
          this._declaration(stream, tok, {colon: x, inParens: true});
          return; // ")" was consumed
        } else if (x) { // (
          this._expr(stream, RPAREN, true);
          reuse = true; // )
        }
      }
      if (reuse !== 0) stream.matchSmart(RPAREN, {must: 1, reuse});
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} tok
     * @param {Token} [paren]
     * @return {boolean|void}
     */
    _containerCondition(stream, tok, paren) {
      if (paren && tok.id === IDENT) {
        stream.unget();
        this._mediaExpression(stream, paren);
      } else if (!paren && tok.name === 'style') {
        this._condition(stream, {id: LPAREN});
      } else {
        return;
      }
      stream.unget();
      return true;
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} [start]
     * @return {string}
     */
    _layerName(stream, start) {
      let res = '';
      let tok;
      while ((tok = !res && start || (res ? stream.match(IDENT) : stream.matchSmart(IDENT)))) {
        res += tok.text;
        if (stream.match(DOT)) res += '.';
        else break;
      }
      return res;
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} start
     */
    _margin(stream, start) {
      this._block(stream, start, {
        decl: true,
        event: ['pagemargin', {margin: start}],
      });
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} [start]
     * @return {Token}
     */
    _mediaExpression(stream, start = stream.grab()) {
      if (start.id !== LPAREN) stream._failure(LPAREN);
      const feature = stream.matchSmart(TT.mediaValue, OrDie);
      feature.expr = this._expr(stream, RPAREN, true); // TODO: alarm on invalid ops
      feature.offset2 = stream.token.offset2; // including ")"
      stream.matchSmart(RPAREN, OrDieReusing);
      return feature;
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} [tok]
     * @return {TokenValue[]}
     */
    _mediaQueryList(stream, tok) {
      const list = [];
      while ((tok = stream.matchSmart(TT.mediaList, {reuse: tok}))) {
        const expr = [];
        const mod = B.notOnly.has(tok) && tok;
        const next = mod ? stream.matchSmart(TT.mediaList, OrDie) : tok;
        const type = next.id === IDENT && next;
        if (!type) expr.push(this._mediaExpression(stream, next));
        for (let more; stream.matchSmart(IDENT, more || (type ? B.and : B.andOr));) {
          if (!more) more = B.and.has(stream.token) ? B.and : B.or;
          expr.push(this._mediaExpression(stream));
        }
        tok = TokenValue.from(expr, mod || next);
        tok.type = type;
        list.push(tok);
        if (!stream.matchSmart(COMMA)) break;
        tok = null;
      }
      return list;
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} tok
     * @param {Token} [paren]
     * @return {boolean|void}
     */
    _supportsCondition(stream, tok, paren) {
      if (!paren && tok.name === 'selector') {
        tok = this._selector(stream);
        stream.unget();
        this.fire({type: 'supportsSelector', selector: tok}, tok);
        return true;
      }
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} start
     */
    _unknownAtRule(stream, start) {
      if (this.options.strict) throw new SyntaxError('Unknown rule: ' + start, start);
      stream.skipDeclBlock();
    }

    //#endregion
    //#region Parser selectors

    /**
     * Warning! The next token is consumed
     * @param {TokenStream} stream
     * @param {Token} [tok]
     * @param {boolean} [relative]
     * @return {Token[]|void}
     */
    _selectorsGroup(stream, tok, relative) {
      const selectors = [];
      let comma;
      while ((tok = this._selector(stream, tok, relative))) {
        selectors.push(tok);
        if ((tok = stream.token).isVar) tok = stream.grab();
        if (!(comma = tok.id === COMMA)) break;
        tok = relative = undefined;
      }
      if (comma) stream._failure();
      if (selectors[0]) return selectors;
    }

    /**
     * Warning! The next token is consumed
     * @param {TokenStream} stream
     * @param {Token} [tok]
     * @param {boolean} [relative]
     * @return {TokenValue|void}
     */
    _selector(stream, tok, relative) {
      const sel = [];
      if (!tok || tok.isVar) {
        tok = stream.grab();
      }
      if (!relative || !isOwn(TT.combinator, tok.id)) {
        tok = this._simpleSelectorSequence(stream, tok);
        if (!tok) return;
        sel.push(tok);
        tok = null;
      }
      for (let combinator, ws; ; tok = null) {
        if (!tok) tok = stream.token;
        if (isOwn(TT.combinator, tok.id)) {
          sel.push(this._combinator(stream, tok));
          sel.push(this._simpleSelectorSequence(stream) || stream._failure());
          continue;
        }
        while (tok.isVar) tok = stream.get();
        ws = tok.id === WS && tok; if (!ws) break;
        tok = stream.grab(); if (tok.id === LBRACE) break;
        combinator = isOwn(TT.combinator, tok.id) && this._combinator(stream, tok);
        tok = this._simpleSelectorSequence(stream, combinator ? undefined : tok);
        if (tok) {
          sel.push(combinator || this._combinator(stream, ws));
          sel.push(tok);
        } else if (combinator) {
          stream._failure();
        }
      }
      return TokenValue.from(sel);
    }

    /**
     * Warning! The next token is consumed
     * @param {TokenStream} stream
     * @param {Token} [start]
     * @return {Token|void}
     */
    _simpleSelectorSequence(stream, start = stream.grab()) {
      let si = start.id; if (!isOwn(TT.selectorStart, si)) return;
      let ns, tag, t2;
      let tok = start;
      const mods = [];
      while (si === AMP) {
        mods.push(Parser.SELECTOR[AMP](stream, tok));
        si = (tok = stream.get()).id;
      }
      if (si === PIPE || (si === STAR || si === IDENT) && (t2 = stream.get()).id === PIPE) {
        ns = t2 ? tok : ''; tok = null;
      } else if (t2) {
        tag = tok; tok = t2;
      }
      if (ns && !(tag = stream.match(TT.identStar))) {
        if (si !== PIPE) stream.unget();
        return;
      }
      while (true) {
        if (!tok) tok = stream.get();
        const fn = Parser.SELECTOR[tok.id];
        if (!(tok = fn && fn.call(this, stream, tok))) break;
        mods.push(tok);
        tok = false;
      }
      tok = Token.from(start);
      tok.ns = ns;
      tok.elementName = tag || '';
      tok.modifiers = mods;
      tok.offset2 = (mods[mods.length - 1] || tok).offset2;
      return tok;
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} [tok]
     * @return {Token}
     */
    _combinator(stream, tok = stream.matchSmart(TT.combinator)) {
      if (tok) tok.type = Combinators[tok.code] || 'unknown';
      return tok;
    }

    //#endregion
    //#region Parser declaration

    /**
     * prop: value ["!" "important"]? [";" | ")"]
     * Consumes ")" when inParens is true.
     * When not inParens `tok` must be already vetted.
     * @param {TokenStream} stream
     * @param {Token} tok
     * @param {{}} [_]
     * @param {boolean} [_.colon] - ":" was consumed
     * @param {boolean} [_.inParens] - (declaration) in conditional media
     * @param {string} [_.scope] - name of section with definitions of valid properties
     * @return {boolean|void}
     */
    _declaration(stream, tok, {colon, inParens, scope} = {}) {
      if (inParens && tok.id !== IDENT) return;
      if (tok.isVar) return true;
      const opts = this.options;
      const hack = tok.hack
        ? (tok = stream.match(IDENT), tok.col--, tok.offset--, '*')
        : tok.code === 95/*_*/ && opts.underscoreHack && '_';
      if (hack) {
        tok.hack = hack;
        PDESC.value = tok.text.slice(1); define(tok, 'text', PDESC);
        PDESC.value = toStringPropHack; define(tok, 'toString', PDESC);
      }
      if (!colon && !stream.match(COLON)) {
        if (inParens || !this._inStyle) {
          stream._failure('":"', stream.get());
        } else {
          stream._failure('prop:value or a selector that is not a tag');
        }
      }
      const isCust = tok.type === 'custom-prop';
      const end = isCust ? TT.propCustomEnd : inParens ? TT.propValEndParen : TT.propValEnd;
      const value = this._expr(stream, end, isCust) ||
        isCust && TokenValue.empty(stream.token) ||
        stream._failure('');
      const invalid = !isCust && !opts.noValidation &&
        validateProperty(tok, value, stream, scope);
      const important = stream.token.id === DELIM &&
        stream.matchSmart(IDENT, {must: 1, text: B.important});
      const ti = stream.matchSmart(inParens ? RPAREN : TT.declEnd, {must: 1, reuse: !important}).id;
      this.fire({
        type: 'property',
        property: tok,
        message: invalid && invalid.message,
        important,
        inParens,
        invalid,
        scope,
        value,
      }, tok);
      if (ti === RBRACE) stream.unget();
      return ti;
    }

    /**
     * @param {TokenStream} stream
     * @param {?} err
     * @param {boolean} [inBlock]
     */
    _declarationFailed(stream, err, inBlock) {
      stream.skipDeclBlock(inBlock);
      this.fire(assign({}, err, {
        type: err.type || 'error',
        recoverable: !stream.source.eof(),
        error: err,
      }));
    }

    /**
     * @param {TokenStream} stream
     * @param {TokenMap|number} end - will be consumed!
     * @param {boolean} [dumb] - <any-value> mode, no additional checks
     * @return {TokenValue|void}
     */
    _expr(stream, end, dumb) {
      const parts = [];
      const isEndMap = typeof end === 'object';
      let /** @type {Token} */ tok, ti, isVar, endParen;
      while ((ti = (tok = stream.get(UVAR)).id) && !(isEndMap ? end[ti] : end === ti)) {
        if ((endParen = Parens[ti])) {
          tok.expr = this._expr(stream, endParen, dumb || ti === LBRACE);
          if (stream.token.id !== endParen) stream._failure(endParen);
          tok.offset2 = stream.token.offset2;
          tok.type = 'block';
        } else if (ti === FUNCTION) {
          if (!tok.ie || this.options.ieFilters) {
            tok = this._function(stream, tok, dumb);
            isVar = isVar || tok.isVar;
          }
        } else if (ti === UVAR) {
          isVar = true;
        } else if (dumb) {
          // No smart processing of tokens in dumb mode, we'll just accumulate the values
        } else if (ti === HASH) {
          this._hexcolor(stream, tok);
        } else if (ti === IDENT && !tok.type) {
          if (B.autoNone.has(tok)) {
            tok[tok.code === 97 ? 'isAuto' : 'isNone'] = true;
            tok.type = 'ident';
          } else {
            tok.type = B.colors.has(tok) ? 'color' : 'ident';
          }
        }
        parts.push(tok);
      }
      if (parts[0]) {
        const res = TokenValue.from(parts);
        if (isVar) res.isVar = true;
        return res;
      }
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} [tok]
     * @param {boolean} [dumb]
     * @return {TokenFunc}
     */
    _function(stream, tok, dumb) {
      return TokenFunc.from(tok, this._expr(stream, RPAREN, dumb), stream.token);
    }

    /**
     * @param {TokenStream} stream
     * @param {Token} tok
     */
    _hexcolor(stream, tok) {
      let text, len, offset, i, c;
      if ((len = tok.length) === 4 || len === 5 || len === 7 || len === 9) {
        if (isOwn(tok, 'text')) text = (offset = 0, tok.text);
        else ({_input: text, offset} = tok);
        for (i = 1; i < len; i++) {
          c = text.charCodeAt(offset + i); // 2-5x faster than slicing+parseInt or regexp
          if ((c < 48 || c > 57) && (c < 65 || c > 70) && (c < 97 || c > 102)) break;
        }
      }
      if (i === len) tok.type = 'color';
      else this.alarm(1, `Expected a hex color but found "${clipString(tok)}".`, tok);
    }

    //#endregion
    //#region Parser rulesets

    /**
     * A style rule i.e. _selectorsGroup { _block }
     * @param {TokenStream} stream
     * @param {Token} tok
     * @param {{}} [opts]
     * @return {true|void}
     */
    _styleRule(stream, tok, opts) {
      // TODO: store isNestedRuleMisplaced in cache to enable caching of nested selectors?
      if (!this._inStyle && parserCache.findBlock(tok)) {
        return true;
      }
      let blk, brace;
      try {
        const amps = tok.id === AMP ? -1 : stream._amp;
        const sels = this._selectorsGroup(stream, tok, true);
        if (!sels) { stream.unget(); return; }
        if (!this._inStyle && (stream._amp > amps || sels.some(isRelativeSelector))) {
          this.alarm(2, 'Nested selector must be inside a style rule.', tok);
        }
        brace = stream.matchSmart(LBRACE, OrDieReusing);
        blk = parserCache.startBlock(sels[0]);
        const msg = {selectors: sels};
        const opts2 = {brace, decl: true, event: ['rule', msg]};
        this._block(stream, sels[0], opts ? assign({}, opts, opts2) : opts2);
        if (!msg.empty) { parserCache.endBlock(); blk = 0; }
      } catch (ex) {
        if (this.options.strict || !(ex instanceof SyntaxError)) throw ex;
        this._declarationFailed(stream, ex, !!brace);
      } finally {
        if (blk) parserCache.cancelBlock(blk);
      }
      return true;
    }

    /**
     * {}-block that can contain _declaration, @-rule, &-prefixed _styleRule
     * @param {TokenStream} stream
     * @param {Token} start
     * @param {{}} [opts]
     * @param {Token} [opts.brace]
     * @param {boolean} [opts.decl] - can contain prop:value declarations
     * @param {Array|{}} [opts.event] - ['name', {...props}?]
     * @param {boolean} [opts.margins] - check for the margin @-rules.
     * @param {boolean} [opts.scoped] - use ScopedProperties for the start token's name
     */
    _block(stream, start, opts = {}) {
      const {margins, scoped, decl, event = []} = opts;
      const {brace = stream.matchSmart(LBRACE, OrDie)} = opts;
      const [type, msg = event[1] = {}] = event || [];
      const declOpts = scoped ? {scope: start.atName} : {};
      const inStyle = (this._inStyle += decl ? 1 : 0);
      const star = inStyle && this.options.starHack && STAR;
      this._stack.push(start);
      let ex, child;
      if (type) this.fire(assign({type: 'start' + type, brace}, msg), start);
      for (let tok, ti, fn; (ti = (tok = stream.get(UVAR)).id) !== RBRACE; ex = null) {
        if (ti === SEMICOLON || ti === UVAR && (child = 1)) {
          continue;
        }
        try {
          if (ti === AT) {
            fn = tok.atName;
            fn = margins && B.marginSyms.has(fn) && this._margin ||
              Parser.AT[fn] ||
              this._unknownAtRule;
            fn.call(this, stream, tok);
            child = 1;
          } else if (inStyle && (ti === IDENT || ti === star && tok.hack)
            ? this._declaration(stream, tok, declOpts)
            : !scoped && (!inStyle || isOwn(TT.nestSel, ti)) && this._styleRule(stream, tok, opts)
          ) {
            child = 1;
          } else {
            ex = stream._failure('', tok, false);
          }
        } catch (e) {
          ex = e;
        }
        if (ex) {
          if (this.options.strict || !(ex instanceof SyntaxError)) break;
          if (inStyle) { this._declarationFailed(stream, ex); ex = null; }
          if (!ti) break;
        }
      }
      this._stack.pop();
      if (decl) this._inStyle--;
      if (ex) throw ex;
      if (type) { msg.empty = !child; this.fire(assign({type: 'end' + type}, msg)); }
    }
  }

  //#endregion
  //#region Parser @-rules

  /** Functions for @ symbols */
  Parser.AT = {
    __proto__: null,

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    charset(stream, start) {
      const charset = stream.matchSmart(Tokens.STRING, OrDie);
      stream.matchSmart(SEMICOLON, OrDie);
      this.fire({type: 'charset', charset}, start);
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    container(stream, start) {
      const tok = stream.matchSmart(IDENT);
      const name = B.not.has(tok) ? stream.unget() : tok;
      this._condition(stream, undefined, this._containerCondition);
      this._block(stream, start, {event: ['container', {name}]});
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    document(stream, start) {
      if (this._stack[0]) this.alarm(2, 'Nested @document produces broken code', start);
      const functions = [];
      do {
        const tok = stream.matchSmart(TT.docFunc);
        const fn = tok.uri ? TokenFunc.from(tok) : tok.name && this._function(stream, tok);
        if (fn && (fn.uri || fn.name === 'regexp')) functions.push(fn);
        else this.alarm(1, 'Unknown document function', fn);
      } while (stream.matchSmart(COMMA));
      const brace = stream.matchSmart(LBRACE, OrDie);
      this.fire({type: 'startdocument', brace, functions, start}, start);
      if (this.options.topDocOnly) {
        stream.skipDeclBlock(true);
        stream.matchSmart(RBRACE, OrDie);
      } else {
        this._block(stream, start, {brace: null});
      }
      this.fire({type: 'enddocument', start, functions});
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    'font-face'(stream, start) {
      this._block(stream, start, {
        decl: true,
        event: ['fontface', {}],
        scoped: true,
      });
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    'font-palette-values'(stream, start) {
      this._block(stream, start, {
        decl: true,
        event: ['fontpalettevalues', {id: stream.matchSmart(IDENT, OrDie)}],
        scoped: true,
      });
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    import(stream, start) {
      let layer, name, tok;
      const uri = (tok = stream.matchSmart(TT.stringUri, OrDie)).uri || tok.string;
      if ((name = (tok = stream.grab()).name) === 'layer' || !name && B.layer.has(tok)) {
        layer = name ? this._layerName(stream) : '';
        if (name) stream.matchSmart(RPAREN, OrDie);
        name = (tok = stream.grab()).name;
      }
      if (name === 'supports') {
        this._conditionInParens(stream, {id: LPAREN});
        tok = null;
      }
      const media = this._mediaQueryList(stream, tok);
      stream.matchSmart(SEMICOLON, OrDie);
      this.fire({type: 'import', layer, media, uri}, start);
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    keyframes(stream, start) {
      const prefix = start.vendorPos ? start.text.slice(0, start.vendorPos) : '';
      const name = stream.matchSmart(TT.identString, OrDie);
      stream.matchSmart(LBRACE, OrDie);
      this.fire({type: 'startkeyframes', name, prefix}, start);
      let tok, ti;
      while (true) {
        const keys = [];
        do {
          ti = (tok = stream.grab()).id;
          if (ti === PCT || ti === IDENT && B.fromTo.has(tok)) keys.push(tok);
          else if (!keys[0]) break;
          else stream._failure('percentage%, "from", "to"', tok);
        } while ((ti = (tok = stream.grab()).id) === COMMA);
        if (!keys[0]) break;
        this._block(stream, keys[0], {
          decl: true,
          brace: ti === LBRACE ? tok : stream.unget(),
          event: ['keyframerule', {keys}],
        });
      }
      if (ti !== RBRACE) stream.matchSmart(RBRACE, OrDie);
      this.fire({type: 'endkeyframes', name, prefix});
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    layer(stream, start) {
      const ids = [];
      let tok;
      do {
        if ((tok = stream.grab()).id === IDENT) {
          ids.push(this._layerName(stream, tok));
          tok = stream.grab();
        }
        if (tok.id === LBRACE) {
          if (this.options.globalsOnly) {
            this.stream.token = start;
            throw Parser.GLOBALS;
          }
          if (ids[1]) this.alarm(1, '@layer block cannot have multiple ids', start);
          this._block(stream, start, {brace: tok, event: ['layer', {id: ids[0]}]});
          return;
        }
      } while (tok.id === COMMA);
      stream.matchSmart(SEMICOLON, {must: 1, reuse: tok});
      this.fire({type: 'layer', ids}, start);
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    media(stream, start) {
      const media = this._mediaQueryList(stream);
      this._block(stream, start, {event: ['media', {media}]});
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    namespace(stream, start) {
      const prefix = stream.matchSmart(IDENT).text;
      const tok = stream.matchSmart(TT.stringUri, OrDie);
      const uri = tok.uri || tok.string;
      stream.matchSmart(SEMICOLON, OrDie);
      this.fire({type: 'namespace', prefix, uri}, start);
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    page(stream, start) {
      const tok = stream.matchSmart(IDENT);
      if (B.auto.has(tok)) stream._failure();
      const id = tok.text;
      const pseudo = stream.match(COLON) && stream.matchOrDie(IDENT).text;
      this._block(stream, start, {
        decl: true,
        event: ['page', {id, pseudo}],
        margins: true,
        scoped: true,
      });
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    property(stream, start) {
      const name = stream.matchSmart(IDENT, OrDie);
      this._block(stream, start, {
        decl: true,
        event: ['property', {name}],
        scoped: true,
      });
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    supports(stream, start) {
      this._condition(stream, undefined, this._supportsCondition);
      this._block(stream, start, {event: ['supports']});
    },
  };

  /** topDocOnly mode */
  Parser.AT_TDO = pick(Parser.AT, ['document']);

  /** @-rules at the top level of the stylesheet */
  Parser.GLOBALS = pick(Parser.AT, ['charset', 'import', 'layer', 'namespace']);

  /** Functions for selectors */
  Parser.SELECTOR = textToTokenMap({

    '&': (stream, tok) => assign(tok, {type: 'amp', args: []}),
    '#': (stream, tok) => assign(tok, {type: 'id', args: []}),

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} tok
     */
    '.'(stream, tok) {
      const t2 = stream.matchOrDie(IDENT);
      if (isOwn(t2, 'text')) tok.text = '.' + t2.text;
      tok.offset2 = t2.offset2;
      tok.type = 'class';
      return tok;
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} start
     */
    '['(stream, start) {
      const t1 = stream.matchSmart(TT.attrStart, OrDie);
      let t2, ns, name, eq, val, mod, end;
      if (t1.id === PIPE) { // [|
        ns = t1;
      } else if (t1.id === STAR) { // [*
        ns = t1;
        ns.offset2 = stream.matchOrDie(PIPE).offset2;
        if (ns.length > 2) ns.text = '*|'; // comment inside
      } else if ((t2 = stream.get()).id === PIPE) { // [ns|
        ns = t1;
        ns.offset2++;
      } else if (isOwn(TT.attrEq, t2.id)) { // [name=, |=, ~=, ^=, *=, $=
        name = t1;
        eq = t2;
      } else if (isOwn(TT.attrNameEnd, t2.id)) { // [name], [name/*[[var]]*/, [name<WS>
        name = t1;
        end = t2.id === RBRACKET && t2;
      } else { // [name<?>
        stream._failure('"]"', t2);
      }
      name = name || stream.matchOrDie(IDENT);
      if (!eq && !end) {
        if ((t2 = stream.matchSmart(TT.attrEqEnd, OrDie)).id === RBRACKET) end = t2; else eq = t2;
      }
      if (eq) {
        val = stream.matchSmart(TT.identString, OrDie);
        if ((t2 = stream.grab()).id === RBRACKET) end = t2;
        else if (B.attrIS.has(t2)) mod = t2;
        else stream._failure(B.attrIS, t2);
      }
      start.args = [
        /*0*/ ns || '',
        /*1*/ name,
        /*2*/ eq || '',
        /*3*/ val || '',
        /*4*/ mod || '',
      ];
      start.type = 'attribute';
      start.offset2 = (end || stream.matchSmart(RBRACKET, OrDie)).offset2;
      return start;
    },

    /**
     * @this {Parser}
     * @param {TokenStream} stream
     * @param {Token} tok
     */
    ':'(stream, tok) {
      const colons = stream.match(COLON) ? '::' : ':';
      tok = stream.matchOrDie(TT.pseudo);
      tok.col -= colons.length;
      tok.offset -= colons.length;
      tok.type = 'pseudo';
      let expr, n, x;
      if ((n = tok.name)) {
        if (n === 'nth-child' || n === 'nth-last-child') {
          expr = stream.readNthChild();
          const t1 = stream.get();
          const t2 = t1.id === WS ? stream.grab() : t1;
          if (expr && B.of.has(t2)) n = 'not';
          else if (t2.id === RPAREN) x = true;
          else stream._failure('', t1);
        }
        if (n === 'not' || n === 'is' || n === 'where' || n === 'any' || n === 'has') {
          x = this._selectorsGroup(stream, undefined, n === 'has');
          if (!x) stream._failure('a selector');
          if (expr) expr.push(...x); else expr = x;
          stream.matchSmart(RPAREN, OrDieReusing);
        } else if (!x) {
          expr = this._expr(stream, RPAREN);
        }
        tok = TokenFunc.from(tok, expr, stream.token);
      }
      tok.args = expr && expr.parts || [];
      return tok;
    },
  });

  //#endregion

  parserlib.css.Parser = Parser;
  parserlib.css.TokenStream = TokenStream;
  parserlib.util.cache = parserCache;

  if (typeof self !== 'undefined') self.parserlib = parserlib;
  else module.exports = parserlib; // eslint-disable-line no-undef
})();
