tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

css-autocompleter.js (46783B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const {
      8  cssTokenizer,
      9  cssTokenizerWithLineColumn,
     10 } = require("resource://devtools/shared/css/parsing-utils.js");
     11 
     12 /**
     13 * Here is what this file (+ css-parsing-utils.js) do.
     14 *
     15 * The main objective here is to provide as much suggestions to the user editing
     16 * a stylesheet in Style Editor. The possible things that can be suggested are:
     17 *  - CSS property names
     18 *  - CSS property values
     19 *  - CSS Selectors
     20 *  - Some other known CSS keywords
     21 *
     22 * Gecko provides a list of both property names and their corresponding values.
     23 * We take out a list of matching selectors using the Inspector actor's
     24 * `getSuggestionsForQuery` method. Now the only thing is to parse the CSS being
     25 * edited by the user, figure out what token or word is being written and last
     26 * but the most difficult, what is being edited.
     27 *
     28 * The file 'css-parsing-utils' helps to convert the CSS into meaningful tokens,
     29 * each having a certain type associated with it. These tokens help us to figure
     30 * out the currently edited word and to write a CSS state machine to figure out
     31 * what the user is currently editing (e.g. a selector or a property or a value,
     32 * or even fine grained information like an id in the selector).
     33 *
     34 * The `resolveState` method iterated over the tokens spitted out by the
     35 * tokenizer, using switch cases, follows a state machine logic and finally
     36 * figures out these informations:
     37 *  - The state of the CSS at the cursor (one out of CSS_STATES)
     38 *  - The current token that is being edited `completing`
     39 *  - If the state is "selector", the selector state (one of SELECTOR_STATES)
     40 *  - If the state is "selector", the current selector till the cursor
     41 *  - If the state is "value", the corresponding property name
     42 *
     43 * In case of "value" and "property" states, we simply use the information
     44 * provided by Gecko to filter out the possible suggestions.
     45 * For "selector" state, we request the Inspector actor to query the page DOM
     46 * and filter out the possible suggestions.
     47 * For "media" and "keyframes" state, the only possible suggestions for now are
     48 * "media" and "keyframes" respectively, although "media" can have suggestions
     49 * like "max-width", "orientation" etc. Similarly "value" state can also have
     50 * much better logical suggestions if we fine grain identify a sub state just
     51 * like we do for the "selector" state.
     52 */
     53 
     54 class CSSCompleter {
     55  // Autocompletion types.
     56 
     57  // These can be read _a lot_ in a hotpath, so keep those as individual constants using
     58  // a Symbol as a value so the lookup is faster.
     59  static CSS_STATE_NULL = Symbol("state_null");
     60  // foo { bar|: … };
     61  static CSS_STATE_PROPERTY = Symbol("state_property");
     62  // foo {bar: baz|};
     63  static CSS_STATE_VALUE = Symbol("state_value");
     64  // f| {bar: baz};
     65  static CSS_STATE_SELECTOR = Symbol("state_selector");
     66  // @med| , or , @media scr| { };
     67  static CSS_STATE_MEDIA = Symbol("state_media");
     68  // @keyf|;
     69  static CSS_STATE_KEYFRAMES = Symbol("state_keyframes");
     70  // @keyframs foobar { t|;
     71  static CSS_STATE_FRAME = Symbol("state_frame");
     72 
     73  static CSS_SELECTOR_STATE_NULL = Symbol("selector_state_null");
     74  // #f|
     75  static CSS_SELECTOR_STATE_ID = Symbol("selector_state_id");
     76  // #foo.b|
     77  static CSS_SELECTOR_STATE_CLASS = Symbol("selector_state_class");
     78  // fo|
     79  static CSS_SELECTOR_STATE_TAG = Symbol("selector_state_tag");
     80  // foo:|
     81  static CSS_SELECTOR_STATE_PSEUDO = Symbol("selector_state_pseudo");
     82  // foo[b|
     83  static CSS_SELECTOR_STATE_ATTRIBUTE = Symbol("selector_state_attribute");
     84  // foo[bar=b|
     85  static CSS_SELECTOR_STATE_VALUE = Symbol("selector_state_value");
     86 
     87  static SELECTOR_STATE_STRING_BY_SYMBOL = new Map([
     88    [CSSCompleter.CSS_SELECTOR_STATE_NULL, "null"],
     89    [CSSCompleter.CSS_SELECTOR_STATE_ID, "id"],
     90    [CSSCompleter.CSS_SELECTOR_STATE_CLASS, "class"],
     91    [CSSCompleter.CSS_SELECTOR_STATE_TAG, "tag"],
     92    [CSSCompleter.CSS_SELECTOR_STATE_PSEUDO, "pseudo"],
     93    [CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE, "attribute"],
     94    [CSSCompleter.CSS_SELECTOR_STATE_VALUE, "value"],
     95  ]);
     96 
     97  /**
     98   * @class
     99   * @param options {Object} An options object containing the following options:
    100   *        - walker {Object} The object used for query selecting from the current
    101   *                 target's DOM.
    102   *        - maxEntries {Number} Maximum selectors suggestions to display.
    103   *        - cssProperties {Object} The database of CSS properties.
    104   */
    105  constructor(options = {}) {
    106    this.walker = options.walker;
    107    this.maxEntries = options.maxEntries || 15;
    108    this.cssProperties = options.cssProperties;
    109 
    110    this.propertyNames = this.cssProperties.getNames().sort();
    111 
    112    // Array containing the [line, ch, scopeStack] for the locations where the
    113    // CSS state is "null"
    114    this.nullStates = [];
    115  }
    116 
    117  /**
    118   * Returns a list of suggestions based on the caret position.
    119   *
    120   * @param source {String} String of the source code.
    121   * @param cursor {Object} Cursor location with line and ch properties.
    122   *
    123   * @returns [{object}] A sorted list of objects containing the following
    124   *          peroperties:
    125   *          - label {String} Full keyword for the suggestion
    126   *          - preLabel {String} Already entered part of the label
    127   */
    128  complete(source, cursor) {
    129    // Getting the context from the caret position.
    130    if (!this.resolveState({ source, line: cursor.line, column: cursor.ch })) {
    131      // We couldn't resolve the context, we won't be able to complete.
    132      return Promise.resolve([]);
    133    }
    134 
    135    // Properly suggest based on the state.
    136    switch (this.state) {
    137      case CSSCompleter.CSS_STATE_PROPERTY:
    138        return this.completeProperties(this.completing);
    139 
    140      case CSSCompleter.CSS_STATE_VALUE:
    141        return this.completeValues(this.propertyName, this.completing);
    142 
    143      case CSSCompleter.CSS_STATE_SELECTOR:
    144        return this.suggestSelectors();
    145 
    146      case CSSCompleter.CSS_STATE_MEDIA:
    147      case CSSCompleter.CSS_STATE_KEYFRAMES:
    148        if ("media".startsWith(this.completing)) {
    149          return Promise.resolve([
    150            {
    151              label: "media",
    152              preLabel: this.completing,
    153              text: "media",
    154            },
    155          ]);
    156        } else if ("keyframes".startsWith(this.completing)) {
    157          return Promise.resolve([
    158            {
    159              label: "keyframes",
    160              preLabel: this.completing,
    161              text: "keyframes",
    162            },
    163          ]);
    164        }
    165    }
    166    return Promise.resolve([]);
    167  }
    168 
    169  /**
    170   * Resolves the state of CSS given a source and a cursor location, or an array of tokens.
    171   * This method implements a custom written CSS state machine. The various switch
    172   * statements provide the transition rules for the state. It also finds out various
    173   * information about the nearby CSS like the property name being completed, the complete
    174   * selector, etc.
    175   *
    176   * @param options {Object}
    177   * @param sourceTokens {Array<InspectorCSSToken>} Optional array of the tokens representing
    178   *                     a CSS source. When this is defined, `source`, `line` and `column`
    179   *                     shouldn't be passed.
    180   * @param options.source {String} Optional string of the source code. When this is defined,
    181   *                       `sourceTokens` shouldn't be passed.
    182   * @param options.line {Number} Cursor line. Mandatory when source is passed.
    183   * @param options.column {Number} Cursor column. Mandatory when source is passed
    184   *
    185   * @returns CSS_STATE
    186   *          One of CSS_STATE enum or null if the state cannot be resolved.
    187   */
    188  // eslint-disable-next-line complexity
    189  resolveState({ sourceTokens, source, line, column }) {
    190    if (sourceTokens && source) {
    191      throw new Error(
    192        "This function only accepts sourceTokens or source, not both"
    193      );
    194    }
    195 
    196    // _state can be one of CSS_STATES;
    197    let _state = CSSCompleter.CSS_STATE_NULL;
    198    let selector = "";
    199    let selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    200    let propertyName = null;
    201    let scopeStack = [];
    202    let selectors = [];
    203 
    204    // If we need to retrieve the tokens, fetch the closest null state line/ch from cached
    205    // null state locations to save some cycle.
    206    const matchedStateIndex = !sourceTokens
    207      ? this.findNearestNullState(line)
    208      : -1;
    209    if (matchedStateIndex > -1) {
    210      const state = this.nullStates[matchedStateIndex];
    211      line -= state[0];
    212      if (line == 0) {
    213        column -= state[1];
    214      }
    215      source = source.split("\n").slice(state[0]);
    216      source[0] = source[0].slice(state[1]);
    217      source = source.join("\n");
    218      scopeStack = [...state[2]];
    219      this.nullStates.length = matchedStateIndex + 1;
    220    } else {
    221      this.nullStates = [];
    222    }
    223 
    224    const tokens = sourceTokens || cssTokenizerWithLineColumn(source);
    225    const tokIndex = tokens.length - 1;
    226 
    227    if (
    228      !sourceTokens &&
    229      tokIndex >= 0 &&
    230      (tokens[tokIndex].loc.end.line < line ||
    231        (tokens[tokIndex].loc.end.line === line &&
    232          tokens[tokIndex].loc.end.column < column))
    233    ) {
    234      // If the last token ends before the cursor location, we didn't
    235      // tokenize it correctly.  This special case can happen if the
    236      // final token is a comment.
    237      return null;
    238    }
    239 
    240    let cursor = 0;
    241    // This will maintain a stack of paired elements like { & }, @m & }, : & ;
    242    // etc
    243    let token = null;
    244    let selectorBeforeNot = null;
    245    while (cursor <= tokIndex && (token = tokens[cursor++])) {
    246      switch (_state) {
    247        case CSSCompleter.CSS_STATE_PROPERTY:
    248          // From CSS_STATE_PROPERTY, we can either go to CSS_STATE_VALUE
    249          // state when we hit the first ':' or CSS_STATE_SELECTOR if "}" is
    250          // reached.
    251          if (token.tokenType === "Colon") {
    252            scopeStack.push(":");
    253            if (tokens[cursor - 2].tokenType != "WhiteSpace") {
    254              propertyName = tokens[cursor - 2].text;
    255            } else {
    256              propertyName = tokens[cursor - 3].text;
    257            }
    258            _state = CSSCompleter.CSS_STATE_VALUE;
    259          }
    260 
    261          if (token.tokenType === "CloseCurlyBracket") {
    262            if (/[{f]/.test(scopeStack.at(-1))) {
    263              const popped = scopeStack.pop();
    264              if (popped == "f") {
    265                _state = CSSCompleter.CSS_STATE_FRAME;
    266              } else {
    267                selector = "";
    268                selectors = [];
    269                _state = CSSCompleter.CSS_STATE_NULL;
    270              }
    271            }
    272          }
    273          break;
    274 
    275        case CSSCompleter.CSS_STATE_VALUE:
    276          // From CSS_STATE_VALUE, we can go to one of CSS_STATE_PROPERTY,
    277          // CSS_STATE_FRAME, CSS_STATE_SELECTOR and CSS_STATE_NULL
    278          if (token.tokenType === "Semicolon") {
    279            if (/[:]/.test(scopeStack.at(-1))) {
    280              scopeStack.pop();
    281              _state = CSSCompleter.CSS_STATE_PROPERTY;
    282            }
    283          }
    284 
    285          if (token.tokenType === "CloseCurlyBracket") {
    286            if (scopeStack.at(-1) == ":") {
    287              scopeStack.pop();
    288            }
    289 
    290            if (/[{f]/.test(scopeStack.at(-1))) {
    291              const popped = scopeStack.pop();
    292              if (popped == "f") {
    293                _state = CSSCompleter.CSS_STATE_FRAME;
    294              } else {
    295                selector = "";
    296                selectors = [];
    297                _state = CSSCompleter.CSS_STATE_NULL;
    298              }
    299            }
    300          }
    301          break;
    302 
    303        case CSSCompleter.CSS_STATE_SELECTOR:
    304          // From CSS_STATE_SELECTOR, we can only go to CSS_STATE_PROPERTY
    305          // when we hit "{"
    306          if (token.tokenType === "CurlyBracketBlock") {
    307            scopeStack.push("{");
    308            _state = CSSCompleter.CSS_STATE_PROPERTY;
    309            selectors.push(selector);
    310            selector = "";
    311            break;
    312          }
    313 
    314          switch (selectorState) {
    315            case CSSCompleter.CSS_SELECTOR_STATE_ID:
    316            case CSSCompleter.CSS_SELECTOR_STATE_CLASS:
    317            case CSSCompleter.CSS_SELECTOR_STATE_TAG:
    318              switch (token.tokenType) {
    319                case "Hash":
    320                case "IDHash":
    321                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
    322                  selector += token.text;
    323                  break;
    324 
    325                case "Delim":
    326                  if (token.text == ".") {
    327                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_CLASS;
    328                    selector += ".";
    329                    if (
    330                      cursor <= tokIndex &&
    331                      tokens[cursor].tokenType == "Ident"
    332                    ) {
    333                      token = tokens[cursor++];
    334                      selector += token.text;
    335                    }
    336                  } else if (token.text == "#") {
    337                    // Lonely # char, that doesn't produce a Hash nor IDHash
    338                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
    339                    selector += "#";
    340                  } else if (
    341                    token.text == "+" ||
    342                    token.text == "~" ||
    343                    token.text == ">"
    344                  ) {
    345                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    346                    selector += token.text;
    347                  }
    348                  break;
    349 
    350                case "Comma":
    351                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    352                  selectors.push(selector);
    353                  selector = "";
    354                  break;
    355 
    356                case "Colon":
    357                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_PSEUDO;
    358                  selector += ":";
    359                  if (cursor > tokIndex) {
    360                    break;
    361                  }
    362 
    363                  token = tokens[cursor++];
    364                  switch (token.tokenType) {
    365                    case "Function":
    366                      if (token.value == "not") {
    367                        selectorBeforeNot = selector;
    368                        selector = "";
    369                        scopeStack.push("(");
    370                      } else {
    371                        selector += token.text;
    372                      }
    373                      selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    374                      break;
    375 
    376                    case "Ident":
    377                      selector += token.text;
    378                      break;
    379                  }
    380                  break;
    381 
    382                case "SquareBracketBlock":
    383                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE;
    384                  scopeStack.push("[");
    385                  selector += "[";
    386                  break;
    387 
    388                case "CloseParenthesis":
    389                  if (scopeStack.at(-1) == "(") {
    390                    scopeStack.pop();
    391                    selector = selectorBeforeNot + "not(" + selector + ")";
    392                    selectorBeforeNot = null;
    393                  } else {
    394                    selector += ")";
    395                  }
    396                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    397                  break;
    398 
    399                case "WhiteSpace":
    400                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    401                  selector && (selector += " ");
    402                  break;
    403              }
    404              break;
    405 
    406            case CSSCompleter.CSS_SELECTOR_STATE_NULL:
    407              // From CSS_SELECTOR_STATE_NULL state, we can go to one of
    408              // CSS_SELECTOR_STATE_ID, CSS_SELECTOR_STATE_CLASS or
    409              // CSS_SELECTOR_STATE_TAG
    410              switch (token.tokenType) {
    411                case "Hash":
    412                case "IDHash":
    413                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
    414                  selector += token.text;
    415                  break;
    416 
    417                case "Ident":
    418                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_TAG;
    419                  selector += token.text;
    420                  break;
    421 
    422                case "Delim":
    423                  if (token.text == ".") {
    424                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_CLASS;
    425                    selector += ".";
    426                    if (
    427                      cursor <= tokIndex &&
    428                      tokens[cursor].tokenType == "Ident"
    429                    ) {
    430                      token = tokens[cursor++];
    431                      selector += token.text;
    432                    }
    433                  } else if (token.text == "#") {
    434                    // Lonely # char, that doesn't produce a Hash nor IDHash
    435                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
    436                    selector += "#";
    437                  } else if (token.text == "*") {
    438                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_TAG;
    439                    selector += "*";
    440                  } else if (
    441                    token.text == "+" ||
    442                    token.text == "~" ||
    443                    token.text == ">"
    444                  ) {
    445                    selector += token.text;
    446                  }
    447                  break;
    448 
    449                case "Comma":
    450                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    451                  selectors.push(selector);
    452                  selector = "";
    453                  break;
    454 
    455                case "Colon":
    456                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_PSEUDO;
    457                  selector += ":";
    458                  if (cursor > tokIndex) {
    459                    break;
    460                  }
    461 
    462                  token = tokens[cursor++];
    463                  switch (token.tokenType) {
    464                    case "Function":
    465                      if (token.value == "not") {
    466                        selectorBeforeNot = selector;
    467                        selector = "";
    468                        scopeStack.push("(");
    469                      } else {
    470                        selector += token.text;
    471                      }
    472                      selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    473                      break;
    474 
    475                    case "Ident":
    476                      selector += token.text;
    477                      break;
    478                  }
    479                  break;
    480 
    481                case "SquareBracketBlock":
    482                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE;
    483                  scopeStack.push("[");
    484                  selector += "[";
    485                  break;
    486 
    487                case "CloseParenthesis":
    488                  if (scopeStack.at(-1) == "(") {
    489                    scopeStack.pop();
    490                    selector = selectorBeforeNot + "not(" + selector + ")";
    491                    selectorBeforeNot = null;
    492                  } else {
    493                    selector += ")";
    494                  }
    495                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    496                  break;
    497 
    498                case "WhiteSpace":
    499                  selector && (selector += " ");
    500                  break;
    501              }
    502              break;
    503 
    504            case CSSCompleter.CSS_SELECTOR_STATE_PSEUDO:
    505              switch (token.tokenType) {
    506                case "Delim":
    507                  if (
    508                    token.text == "+" ||
    509                    token.text == "~" ||
    510                    token.text == ">"
    511                  ) {
    512                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    513                    selector += token.text;
    514                  }
    515                  break;
    516 
    517                case "Comma":
    518                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    519                  selectors.push(selector);
    520                  selector = "";
    521                  break;
    522 
    523                case "Colon":
    524                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_PSEUDO;
    525                  selector += ":";
    526                  if (cursor > tokIndex) {
    527                    break;
    528                  }
    529 
    530                  token = tokens[cursor++];
    531                  switch (token.tokenType) {
    532                    case "Function":
    533                      if (token.value == "not") {
    534                        selectorBeforeNot = selector;
    535                        selector = "";
    536                        scopeStack.push("(");
    537                      } else {
    538                        selector += token.text;
    539                      }
    540                      selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    541                      break;
    542 
    543                    case "Ident":
    544                      selector += token.text;
    545                      break;
    546                  }
    547                  break;
    548                case "SquareBracketBlock":
    549                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE;
    550                  scopeStack.push("[");
    551                  selector += "[";
    552                  break;
    553 
    554                case "WhiteSpace":
    555                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    556                  selector && (selector += " ");
    557                  break;
    558              }
    559              break;
    560 
    561            case CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE:
    562              switch (token.tokenType) {
    563                case "IncludeMatch":
    564                case "DashMatch":
    565                case "PrefixMatch":
    566                case "IncludeSuffixMatchMatch":
    567                case "SubstringMatch":
    568                  selector += token.text;
    569                  token = tokens[cursor++];
    570                  break;
    571 
    572                case "Delim":
    573                  if (token.text == "=") {
    574                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_VALUE;
    575                    selector += token.text;
    576                  }
    577                  break;
    578 
    579                case "CloseSquareBracket":
    580                  if (scopeStack.at(-1) == "[") {
    581                    scopeStack.pop();
    582                  }
    583 
    584                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    585                  selector += "]";
    586                  break;
    587 
    588                case "Ident":
    589                  selector += token.text;
    590                  break;
    591 
    592                case "QuotedString":
    593                  selector += token.value;
    594                  break;
    595 
    596                case "WhiteSpace":
    597                  selector && (selector += " ");
    598                  break;
    599              }
    600              break;
    601 
    602            case CSSCompleter.CSS_SELECTOR_STATE_VALUE:
    603              switch (token.tokenType) {
    604                case "Ident":
    605                  selector += token.text;
    606                  break;
    607 
    608                case "QuotedString":
    609                  selector += token.value;
    610                  break;
    611 
    612                case "CloseSquareBracket":
    613                  if (scopeStack.at(-1) == "[") {
    614                    scopeStack.pop();
    615                  }
    616 
    617                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    618                  selector += "]";
    619                  break;
    620 
    621                case "WhiteSpace":
    622                  selector && (selector += " ");
    623                  break;
    624              }
    625              break;
    626          }
    627          break;
    628 
    629        case CSSCompleter.CSS_STATE_NULL:
    630          // From CSS_STATE_NULL state, we can go to either CSS_STATE_MEDIA or
    631          // CSS_STATE_SELECTOR.
    632          switch (token.tokenType) {
    633            case "Hash":
    634            case "IDHash":
    635              selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
    636              selector = token.text;
    637              _state = CSSCompleter.CSS_STATE_SELECTOR;
    638              break;
    639 
    640            case "Ident":
    641              selectorState = CSSCompleter.CSS_SELECTOR_STATE_TAG;
    642              selector = token.text;
    643              _state = CSSCompleter.CSS_STATE_SELECTOR;
    644              break;
    645 
    646            case "Delim":
    647              if (token.text == ".") {
    648                selectorState = CSSCompleter.CSS_SELECTOR_STATE_CLASS;
    649                selector = ".";
    650                _state = CSSCompleter.CSS_STATE_SELECTOR;
    651                if (cursor <= tokIndex && tokens[cursor].tokenType == "Ident") {
    652                  token = tokens[cursor++];
    653                  selector += token.text;
    654                }
    655              } else if (token.text == "#") {
    656                // Lonely # char, that doesn't produce a Hash nor IDHash
    657                selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
    658                selector = "#";
    659                _state = CSSCompleter.CSS_STATE_SELECTOR;
    660              } else if (token.text == "*") {
    661                selectorState = CSSCompleter.CSS_SELECTOR_STATE_TAG;
    662                selector = "*";
    663                _state = CSSCompleter.CSS_STATE_SELECTOR;
    664              }
    665              break;
    666 
    667            case "Colon":
    668              _state = CSSCompleter.CSS_STATE_SELECTOR;
    669              selectorState = CSSCompleter.CSS_SELECTOR_STATE_PSEUDO;
    670              selector += ":";
    671              if (cursor > tokIndex) {
    672                break;
    673              }
    674 
    675              token = tokens[cursor++];
    676              switch (token.tokenType) {
    677                case "Function":
    678                  if (token.value == "not") {
    679                    selectorBeforeNot = selector;
    680                    selector = "";
    681                    scopeStack.push("(");
    682                  } else {
    683                    selector += token.text;
    684                  }
    685                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    686                  break;
    687 
    688                case "Ident":
    689                  selector += token.text;
    690                  break;
    691              }
    692              break;
    693 
    694            case "CloseSquareBracket":
    695              _state = CSSCompleter.CSS_STATE_SELECTOR;
    696              selectorState = CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE;
    697              scopeStack.push("[");
    698              selector += "[";
    699              break;
    700 
    701            case "CurlyBracketBlock":
    702              if (scopeStack.at(-1) == "@m") {
    703                scopeStack.pop();
    704              }
    705              break;
    706 
    707            case "AtKeyword":
    708              // XXX: We should probably handle other at-rules (@container, @property, …)
    709              _state = token.value.startsWith("m")
    710                ? CSSCompleter.CSS_STATE_MEDIA
    711                : CSSCompleter.CSS_STATE_KEYFRAMES;
    712              break;
    713          }
    714          break;
    715 
    716        case CSSCompleter.CSS_STATE_MEDIA:
    717          // From CSS_STATE_MEDIA, we can only go to CSS_STATE_NULL state when
    718          // we hit the first '{'
    719          if (token.tokenType == "CurlyBracketBlock") {
    720            scopeStack.push("@m");
    721            _state = CSSCompleter.CSS_STATE_NULL;
    722          }
    723          break;
    724 
    725        case CSSCompleter.CSS_STATE_KEYFRAMES:
    726          // From CSS_STATE_KEYFRAMES, we can only go to CSS_STATE_FRAME state
    727          // when we hit the first '{'
    728          if (token.tokenType == "CurlyBracketBlock") {
    729            scopeStack.push("@k");
    730            _state = CSSCompleter.CSS_STATE_FRAME;
    731          }
    732          break;
    733 
    734        case CSSCompleter.CSS_STATE_FRAME:
    735          // From CSS_STATE_FRAME, we can either go to CSS_STATE_PROPERTY
    736          // state when we hit the first '{' or to CSS_STATE_SELECTOR when we
    737          // hit '}'
    738          if (token.tokenType == "CurlyBracketBlock") {
    739            scopeStack.push("f");
    740            _state = CSSCompleter.CSS_STATE_PROPERTY;
    741          } else if (token.tokenType == "CloseCurlyBracket") {
    742            if (scopeStack.at(-1) == "@k") {
    743              scopeStack.pop();
    744            }
    745 
    746            _state = CSSCompleter.CSS_STATE_NULL;
    747          }
    748          break;
    749      }
    750      if (_state == CSSCompleter.CSS_STATE_NULL) {
    751        if (!this.nullStates.length) {
    752          this.nullStates.push([
    753            token.loc.end.line,
    754            token.loc.end.column,
    755            [...scopeStack],
    756          ]);
    757          continue;
    758        }
    759        let tokenLine = token.loc.end.line;
    760        const tokenCh = token.loc.end.column;
    761        if (tokenLine == 0) {
    762          continue;
    763        }
    764        if (matchedStateIndex > -1) {
    765          tokenLine += this.nullStates[matchedStateIndex][0];
    766        }
    767        this.nullStates.push([tokenLine, tokenCh, [...scopeStack]]);
    768      }
    769    }
    770    // ^ while loop end
    771 
    772    this.state = _state;
    773    this.propertyName =
    774      _state == CSSCompleter.CSS_STATE_VALUE ? propertyName : null;
    775    this.selectorState =
    776      _state == CSSCompleter.CSS_STATE_SELECTOR ? selectorState : null;
    777    this.selectorBeforeNot =
    778      selectorBeforeNot == null ? null : selectorBeforeNot;
    779    if (token) {
    780      // If the source text is passed, we need to remove the part of the computed selector
    781      // after the caret (when sourceTokens are passed, the last token is already sliced,
    782      // so we'll get the expected value)
    783      if (!sourceTokens) {
    784        selector = selector.slice(
    785          0,
    786          selector.length + token.loc.end.column - column
    787        );
    788      }
    789      this.selector = selector;
    790    } else {
    791      this.selector = "";
    792    }
    793    this.selectors = selectors;
    794 
    795    if (token && token.tokenType != "WhiteSpace") {
    796      let text;
    797      if (
    798        token.tokenType === "IDHash" ||
    799        token.tokenType === "Hash" ||
    800        token.tokenType === "AtKeyword" ||
    801        token.tokenType === "Function" ||
    802        token.tokenType === "QuotedString"
    803      ) {
    804        text = token.value;
    805      } else {
    806        text = token.text;
    807      }
    808      this.completing = (
    809        sourceTokens
    810          ? text
    811          : // If the source text is passed, we need to remove the text after the caret
    812            // (when sourceTokens are passed, the last token is already sliced, so we'll
    813            // get the expected value)
    814            text.slice(0, column - token.loc.start.column)
    815      ).replace(/^[.#]$/, "");
    816    } else {
    817      this.completing = "";
    818    }
    819    // Special case the situation when the user just entered ":" after typing a
    820    // property name.
    821    if (this.completing == ":" && _state == CSSCompleter.CSS_STATE_VALUE) {
    822      this.completing = "";
    823    }
    824 
    825    // Special check for !important; case.
    826    if (
    827      token &&
    828      tokens[cursor - 2] &&
    829      tokens[cursor - 2].text == "!" &&
    830      this.completing == "important".slice(0, this.completing.length)
    831    ) {
    832      this.completing = "!" + this.completing;
    833    }
    834    return _state;
    835  }
    836 
    837  /**
    838   * Queries the DOM Walker actor for suggestions regarding the selector being
    839   * completed
    840   */
    841  suggestSelectors() {
    842    const walker = this.walker;
    843    if (!walker) {
    844      return Promise.resolve([]);
    845    }
    846 
    847    let query = this.selector;
    848    // Even though the selector matched atleast one node, there is still
    849    // possibility of suggestions.
    850    switch (this.selectorState) {
    851      case CSSCompleter.CSS_SELECTOR_STATE_NULL:
    852        if (this.completing === ",") {
    853          return Promise.resolve([]);
    854        }
    855 
    856        query += "*";
    857        break;
    858 
    859      case CSSCompleter.CSS_SELECTOR_STATE_TAG:
    860        query = query.slice(0, query.length - this.completing.length);
    861        break;
    862 
    863      case CSSCompleter.CSS_SELECTOR_STATE_ID:
    864      case CSSCompleter.CSS_SELECTOR_STATE_CLASS:
    865      case CSSCompleter.CSS_SELECTOR_STATE_PSEUDO:
    866        if (/^[.:#]$/.test(this.completing)) {
    867          query = query.slice(0, query.length - this.completing.length);
    868          this.completing = "";
    869        } else {
    870          query = query.slice(0, query.length - this.completing.length - 1);
    871        }
    872        break;
    873    }
    874 
    875    if (
    876      /[\s+>~]$/.test(query) &&
    877      this.selectorState != CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE &&
    878      this.selectorState != CSSCompleter.CSS_SELECTOR_STATE_VALUE
    879    ) {
    880      query += "*";
    881    }
    882 
    883    // Set the values that this request was supposed to suggest to.
    884    this._currentQuery = query;
    885    return walker
    886      .getSuggestionsForQuery(
    887        query,
    888        this.completing,
    889        CSSCompleter.SELECTOR_STATE_STRING_BY_SYMBOL.get(this.selectorState)
    890      )
    891      .then(result => this.prepareSelectorResults(result));
    892  }
    893 
    894  /**
    895   * Prepares the selector suggestions returned by the walker actor.
    896   */
    897  prepareSelectorResults(result) {
    898    if (this._currentQuery != result.query) {
    899      return [];
    900    }
    901 
    902    const { suggestions } = result;
    903    const query = this.selector;
    904    const completion = [];
    905 
    906    for (const suggestion of suggestions) {
    907      let [value, state] = suggestion;
    908 
    909      switch (this.selectorState) {
    910        case CSSCompleter.CSS_SELECTOR_STATE_ID:
    911        case CSSCompleter.CSS_SELECTOR_STATE_CLASS:
    912        case CSSCompleter.CSS_SELECTOR_STATE_PSEUDO:
    913          if (/^[.:#]$/.test(this.completing)) {
    914            value =
    915              query.slice(0, query.length - this.completing.length) + value;
    916          } else {
    917            value =
    918              query.slice(0, query.length - this.completing.length - 1) + value;
    919          }
    920          break;
    921 
    922        case CSSCompleter.CSS_SELECTOR_STATE_TAG:
    923          value = query.slice(0, query.length - this.completing.length) + value;
    924          break;
    925 
    926        case CSSCompleter.CSS_SELECTOR_STATE_NULL:
    927          value = query + value;
    928          break;
    929 
    930        default:
    931          value = query.slice(0, query.length - this.completing.length) + value;
    932      }
    933 
    934      const item = {
    935        label: value,
    936        preLabel: query,
    937        text: value,
    938      };
    939 
    940      // In case the query's state is tag and the item's state is id or class
    941      // adjust the preLabel
    942      if (
    943        this.selectorState === CSSCompleter.CSS_SELECTOR_STATE_TAG &&
    944        state === CSSCompleter.CSS_SELECTOR_STATE_CLASS
    945      ) {
    946        item.preLabel = "." + item.preLabel;
    947      }
    948      if (
    949        this.selectorState === CSSCompleter.CSS_SELECTOR_STATE_TAG &&
    950        state === CSSCompleter.CSS_SELECTOR_STATE_ID
    951      ) {
    952        item.preLabel = "#" + item.preLabel;
    953      }
    954 
    955      completion.push(item);
    956 
    957      if (completion.length > this.maxEntries - 1) {
    958        break;
    959      }
    960    }
    961    return completion;
    962  }
    963 
    964  /**
    965   * Returns CSS property name suggestions based on the input.
    966   *
    967   * @param startProp {String} Initial part of the property being completed.
    968   */
    969  completeProperties(startProp) {
    970    const finalList = [];
    971    if (!startProp) {
    972      return Promise.resolve(finalList);
    973    }
    974 
    975    const length = this.propertyNames.length;
    976    let i = 0,
    977      count = 0;
    978    for (; i < length && count < this.maxEntries; i++) {
    979      if (this.propertyNames[i].startsWith(startProp)) {
    980        count++;
    981        const propName = this.propertyNames[i];
    982        finalList.push({
    983          preLabel: startProp,
    984          label: propName,
    985          text: propName + ": ",
    986        });
    987      } else if (this.propertyNames[i] > startProp) {
    988        // We have crossed all possible matches alphabetically.
    989        break;
    990      }
    991    }
    992    return Promise.resolve(finalList);
    993  }
    994 
    995  /**
    996   * Returns CSS value suggestions based on the corresponding property.
    997   *
    998   * @param propName {String} The property to which the value being completed
    999   *        belongs.
   1000   * @param startValue {String} Initial part of the value being completed.
   1001   */
   1002  completeValues(propName, startValue) {
   1003    const finalList = [];
   1004    const list = ["!important;", ...this.cssProperties.getValues(propName)];
   1005    // If there is no character being completed, we are showing an initial list
   1006    // of possible values. Skipping '!important' in this case.
   1007    if (!startValue) {
   1008      list.splice(0, 1);
   1009    }
   1010 
   1011    const length = list.length;
   1012    let i = 0,
   1013      count = 0;
   1014    for (; i < length && count < this.maxEntries; i++) {
   1015      if (list[i].startsWith(startValue)) {
   1016        count++;
   1017        const value = list[i];
   1018        finalList.push({
   1019          preLabel: startValue,
   1020          label: value,
   1021          text: value,
   1022        });
   1023      } else if (list[i] > startValue) {
   1024        // We have crossed all possible matches alphabetically.
   1025        break;
   1026      }
   1027    }
   1028    return Promise.resolve(finalList);
   1029  }
   1030 
   1031  /**
   1032   * A biased binary search in a sorted array where the middle element is
   1033   * calculated based on the values at the lower and the upper index in each
   1034   * iteration.
   1035   *
   1036   * This method returns the index of the closest null state from the passed
   1037   * `line` argument. Once we have the closest null state, we can start applying
   1038   * the state machine logic from that location instead of the absolute starting
   1039   * of the CSS source. This speeds up the tokenizing and the state machine a
   1040   * lot while using autocompletion at high line numbers in a CSS source.
   1041   */
   1042  findNearestNullState(line) {
   1043    const arr = this.nullStates;
   1044    let high = arr.length - 1;
   1045    let low = 0;
   1046    let target = 0;
   1047 
   1048    if (high < 0) {
   1049      return -1;
   1050    }
   1051    if (arr[high][0] <= line) {
   1052      return high;
   1053    }
   1054    if (arr[low][0] > line) {
   1055      return -1;
   1056    }
   1057 
   1058    while (high > low) {
   1059      if (arr[low][0] <= line && arr[low[0] + 1] > line) {
   1060        return low;
   1061      }
   1062      if (arr[high][0] > line && arr[high - 1][0] <= line) {
   1063        return high - 1;
   1064      }
   1065 
   1066      target =
   1067        (((line - arr[low][0]) / (arr[high][0] - arr[low][0])) * (high - low)) |
   1068        0;
   1069 
   1070      if (arr[target][0] <= line && arr[target + 1][0] > line) {
   1071        return target;
   1072      } else if (line > arr[target][0]) {
   1073        low = target + 1;
   1074        high--;
   1075      } else {
   1076        high = target - 1;
   1077        low++;
   1078      }
   1079    }
   1080 
   1081    return -1;
   1082  }
   1083 
   1084  /**
   1085   * Invalidates the state cache for and above the line.
   1086   */
   1087  invalidateCache(line) {
   1088    this.nullStates.length = this.findNearestNullState(line) + 1;
   1089  }
   1090 
   1091  /**
   1092   * Get the state information about a token surrounding the {line, ch} position
   1093   *
   1094   * @param {string} source
   1095   *        The complete source of the CSS file. Unlike resolve state method,
   1096   *        this method requires the full source.
   1097   * @param {object} caret
   1098   *        The line, ch position of the caret.
   1099   *
   1100   * @returns {object}
   1101   *          An object containing the state of token covered by the caret.
   1102   *          The object has following properties when the the state is
   1103   *          "selector", "value" or "property", null otherwise:
   1104   *           - state {string} one of CSS_STATES - "selector", "value" etc.
   1105   *           - selector {string} The selector at the caret when `state` is
   1106   *                      selector. OR
   1107   *           - selectors {[string]} Array of selector strings in case when
   1108   *                       `state` is "value" or "property"
   1109   *           - propertyName {string} The property name at the current caret or
   1110   *                          the property name corresponding to the value at
   1111   *                          the caret.
   1112   *           - value {string} The css value at the current caret.
   1113   *           - loc {object} An object containing the starting and the ending
   1114   *                 caret position of the whole selector, value or property.
   1115   *                  - { start: {line, ch}, end: {line, ch}}
   1116   */
   1117  getInfoAt(source, caret) {
   1118    const { line, ch } = caret;
   1119    const sourceArray = source.split("\n");
   1120 
   1121    // Limits the input source till the {line, ch} caret position
   1122    const limit = function () {
   1123      // `line` is 0-based
   1124      if (sourceArray.length <= line) {
   1125        return source;
   1126      }
   1127      const list = sourceArray.slice(0, line + 1);
   1128      list[line] = list[line].slice(0, ch);
   1129      return list.join("\n");
   1130    };
   1131 
   1132    const limitedSource = limit(source);
   1133 
   1134    // Ideally we should be using `cssTokenizer`, which parse incrementaly and returns a generator.
   1135    // `cssTokenizerWithLineColumn` parses the whole `limitedSource` content right away
   1136    // and returns an array of tokens. This can be a performance bottleneck,
   1137    // but `resolveState` would go through all the tokens anyway, as well as `traverseBackward`,
   1138    // which starts from the last token.
   1139    const limitedSourceTokens = cssTokenizerWithLineColumn(limitedSource);
   1140    const state = this.resolveState({
   1141      sourceTokens: limitedSourceTokens,
   1142    });
   1143    const propertyName = this.propertyName;
   1144 
   1145    /**
   1146     * Method to traverse forwards from the caret location to figure out the
   1147     * ending point of a selector or css value.
   1148     *
   1149     * @param {function} check
   1150     *        A method which takes the current state as an input and determines
   1151     *        whether the state changed or not.
   1152     */
   1153    const traverseForward = check => {
   1154      let forwardCurrentLine = line;
   1155      let forwardCurrentSource = limitedSource;
   1156 
   1157      // loop to determine the end location of the property name/value/selector.
   1158      do {
   1159        let lineText = sourceArray[forwardCurrentLine];
   1160        if (forwardCurrentLine == line) {
   1161          lineText = lineText.substring(ch);
   1162        }
   1163 
   1164        let prevToken = undefined;
   1165        const tokensIterator = cssTokenizer(lineText);
   1166 
   1167        const ech = forwardCurrentLine == line ? ch : 0;
   1168        for (let token of tokensIterator) {
   1169          forwardCurrentSource += sourceArray[forwardCurrentLine].substring(
   1170            ech + token.startOffset,
   1171            ech + token.endOffset
   1172          );
   1173 
   1174          // WhiteSpace cannot change state.
   1175          if (token.tokenType == "WhiteSpace") {
   1176            prevToken = token;
   1177            continue;
   1178          }
   1179 
   1180          const forwState = this.resolveState({
   1181            source: forwardCurrentSource,
   1182            line: forwardCurrentLine,
   1183            column: token.endOffset + ech,
   1184          });
   1185          if (check(forwState)) {
   1186            if (prevToken && prevToken.tokenType == "WhiteSpace") {
   1187              token = prevToken;
   1188            }
   1189            return {
   1190              line: forwardCurrentLine,
   1191              ch: token.startOffset + ech,
   1192            };
   1193          }
   1194          prevToken = token;
   1195        }
   1196        forwardCurrentSource += "\n";
   1197      } while (++forwardCurrentLine < sourceArray.length);
   1198      return null;
   1199    };
   1200 
   1201    /**
   1202     * Method to traverse backwards from the caret location to figure out the
   1203     * starting point of a selector or css value.
   1204     *
   1205     * @param {function} check
   1206     *        A method which takes the current state as an input and determines
   1207     *        whether the state changed or not.
   1208     * @param {boolean} isValue
   1209     *        true if the traversal is being done for a css value state.
   1210     */
   1211    const traverseBackwards = (check, isValue) => {
   1212      let token;
   1213      let previousToken;
   1214      const remainingTokens = Array.from(limitedSourceTokens);
   1215 
   1216      // Backward loop to determine the beginning location of the selector.
   1217      while (((previousToken = token), (token = remainingTokens.pop()))) {
   1218        // WhiteSpace cannot change state.
   1219        if (token.tokenType == "WhiteSpace") {
   1220          continue;
   1221        }
   1222 
   1223        const backState = this.resolveState({
   1224          sourceTokens: remainingTokens,
   1225        });
   1226        if (check(backState)) {
   1227          if (previousToken?.tokenType == "WhiteSpace") {
   1228            token = previousToken;
   1229          }
   1230 
   1231          const loc = isValue ? token.loc.end : token.loc.start;
   1232          return {
   1233            line: loc.line,
   1234            ch: loc.column,
   1235          };
   1236        }
   1237      }
   1238      return null;
   1239    };
   1240 
   1241    if (state == CSSCompleter.CSS_STATE_SELECTOR) {
   1242      // For selector state, the ending and starting point of the selector is
   1243      // either when the state changes or the selector becomes empty and a
   1244      // single selector can span multiple lines.
   1245      // Backward loop to determine the beginning location of the selector.
   1246      const start = traverseBackwards(backState => {
   1247        return (
   1248          backState != CSSCompleter.CSS_STATE_SELECTOR ||
   1249          (this.selector == "" && this.selectorBeforeNot == null)
   1250        );
   1251      });
   1252 
   1253      // Forward loop to determine the ending location of the selector.
   1254      const end = traverseForward(forwState => {
   1255        return (
   1256          forwState != CSSCompleter.CSS_STATE_SELECTOR ||
   1257          (this.selector == "" && this.selectorBeforeNot == null)
   1258        );
   1259      });
   1260 
   1261      // Since we have start and end positions, figure out the whole selector.
   1262      let selector = sourceArray.slice(start.line, end.line + 1);
   1263      selector[selector.length - 1] = selector[selector.length - 1].substring(
   1264        0,
   1265        end.ch
   1266      );
   1267      selector[0] = selector[0].substring(start.ch);
   1268      selector = selector.join("\n");
   1269      return {
   1270        state,
   1271        selector,
   1272        loc: {
   1273          start,
   1274          end,
   1275        },
   1276      };
   1277    } else if (state == CSSCompleter.CSS_STATE_PROPERTY) {
   1278      // A property can only be a single word and thus very easy to calculate.
   1279      const tokensIterator = cssTokenizer(sourceArray[line]);
   1280      for (const token of tokensIterator) {
   1281        // Note that, because we're tokenizing a single line, the
   1282        // token's offset is also the column number.
   1283        if (token.startOffset <= ch && token.endOffset >= ch) {
   1284          return {
   1285            state,
   1286            propertyName: token.text,
   1287            selectors: this.selectors,
   1288            loc: {
   1289              start: {
   1290                line,
   1291                ch: token.startOffset,
   1292              },
   1293              end: {
   1294                line,
   1295                ch: token.endOffset,
   1296              },
   1297            },
   1298          };
   1299        }
   1300      }
   1301    } else if (state == CSSCompleter.CSS_STATE_VALUE) {
   1302      // CSS value can be multiline too, so we go forward and backwards to
   1303      // determine the bounds of the value at caret
   1304      const start = traverseBackwards(
   1305        backState => backState != CSSCompleter.CSS_STATE_VALUE,
   1306        true
   1307      );
   1308 
   1309      // Find the end of the value using a simple forward scan.
   1310      const remainingSource = source.substring(limitedSource.length);
   1311      const parser = new InspectorCSSParser(remainingSource);
   1312      let end;
   1313      while (true) {
   1314        const token = parser.nextToken();
   1315        if (
   1316          !token ||
   1317          token.tokenType === "Semicolon" ||
   1318          token.tokenType === "CloseCurlyBracket"
   1319        ) {
   1320          // Done.  We're guaranteed to exit the loop once we reach
   1321          // the end of the string.
   1322          end = {
   1323            line: parser.lineNumber + line,
   1324            ch: parser.columnNumber,
   1325          };
   1326          if (end.line === line) {
   1327            end.ch = end.ch + ch;
   1328          }
   1329          break;
   1330        }
   1331      }
   1332 
   1333      let value = sourceArray.slice(start.line, end.line + 1);
   1334      value[value.length - 1] = value[value.length - 1].substring(0, end.ch);
   1335      value[0] = value[0].substring(start.ch);
   1336      value = value.join("\n");
   1337      return {
   1338        state,
   1339        propertyName,
   1340        selectors: this.selectors,
   1341        value,
   1342        loc: {
   1343          start,
   1344          end,
   1345        },
   1346      };
   1347    }
   1348    return null;
   1349  }
   1350 }
   1351 
   1352 module.exports = CSSCompleter;