browser/devtools/sourceeditor/css-autocompleter.js

Wed, 31 Dec 2014 06:09:35 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 06:09:35 +0100
changeset 0
6474c204b198
permissions
-rw-r--r--

Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.

     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/. */
     5 const { Cc, Ci, Cu } = require('chrome');
     6 const cssTokenizer  = require("devtools/sourceeditor/css-tokenizer");
     7 const promise = Cu.import("resource://gre/modules/Promise.jsm");
     9 /**
    10  * Here is what this file (+ ./css-tokenizer.js) do.
    11  *
    12  * The main objective here is to provide as much suggestions to the user editing
    13  * a stylesheet in Style Editor. The possible things that can be suggested are:
    14  *  - CSS property names
    15  *  - CSS property values
    16  *  - CSS Selectors
    17  *  - Some other known CSS keywords
    18  *
    19  * Gecko provides a list of both property names and their corresponding values.
    20  * We take out a list of matching selectors using the Inspector actor's
    21  * `getSuggestionsForQuery` method. Now the only thing is to parse the CSS being
    22  * edited by the user, figure out what token or word is being written and last
    23  * but the most difficult, what is being edited.
    24  *
    25  * The file 'css-tokenizer' helps in converting the CSS into meaningful tokens,
    26  * each having a certain type associated with it. These tokens help us to figure
    27  * out the currently edited word and to write a CSS state machine to figure out
    28  * what the user is currently editing. By that, I mean, whether he is editing a
    29  * selector or a property or a value, or even fine grained information like an
    30  * id in the selector.
    31  *
    32  * The `resolveState` method iterated over the tokens spitted out by the
    33  * tokenizer, using switch cases, follows a state machine logic and finally
    34  * figures out these informations:
    35  *  - The state of the CSS at the cursor (one out of CSS_STATES)
    36  *  - The current token that is being edited `cmpleting`
    37  *  - If the state is "selector", the selector state (one of SELECTOR_STATES)
    38  *  - If the state is "selector", the current selector till the cursor
    39  *  - If the state is "value", the corresponding property name
    40  *
    41  * In case of "value" and "property" states, we simply use the information
    42  * provided by Gecko to filter out the possible suggestions.
    43  * For "selector" state, we request the Inspector actor to query the page DOM
    44  * and filter out the possible suggestions.
    45  * For "media" and "keyframes" state, the only possible suggestions for now are
    46  * "media" and "keyframes" respectively, although "media" can have suggestions
    47  * like "max-width", "orientation" etc. Similarly "value" state can also have
    48  * much better logical suggestions if we fine grain identify a sub state just
    49  * like we do for the "selector" state.
    50  */
    52 // Autocompletion types.
    54 const CSS_STATES = {
    55   "null": "null",
    56   property: "property",    // foo { bar|: … }
    57   value: "value",          // foo {bar: baz|}
    58   selector: "selector",    // f| {bar: baz}
    59   media: "media",          // @med| , or , @media scr| { }
    60   keyframes: "keyframes",  // @keyf|
    61   frame: "frame",          // @keyframs foobar { t|
    62 };
    64 const SELECTOR_STATES = {
    65   "null": "null",
    66   id: "id",                // #f|
    67   class: "class",          // #foo.b|
    68   tag: "tag",              // fo|
    69   pseudo: "pseudo",        // foo:|
    70   attribute: "attribute",  // foo[b|
    71   value: "value",          // foo[bar=b|
    72 };
    74 const { properties, propertyNames } = getCSSKeywords();
    76 /**
    77  * Constructor for the autocompletion object.
    78  *
    79  * @param options {Object} An options object containing the following options:
    80  *        - walker {Object} The object used for query selecting from the current
    81  *                 target's DOM.
    82  *        - maxEntries {Number} Maximum selectors suggestions to display.
    83  */
    84 function CSSCompleter(options = {}) {
    85   this.walker = options.walker;
    86   this.maxEntries = options.maxEntries || 15;
    88   // Array containing the [line, ch, scopeStack] for the locations where the
    89   // CSS state is "null"
    90   this.nullStates = [];
    91 }
    93 CSSCompleter.prototype = {
    95   /**
    96    * Returns a list of suggestions based on the caret position.
    97    *
    98    * @param source {String} String of the source code.
    99    * @param caret {Object} Cursor location with line and ch properties.
   100    *
   101    * @returns [{object}] A sorted list of objects containing the following
   102    *          peroperties:
   103    *          - label {String} Full keyword for the suggestion
   104    *          - preLabel {String} Already entered part of the label
   105    */
   106   complete: function(source, caret) {
   107     // Getting the context from the caret position.
   108     if (!this.resolveState(source, caret)) {
   109       // We couldn't resolve the context, we won't be able to complete.
   110       return Promise.resolve([]);
   111     }
   113     // Properly suggest based on the state.
   114     switch(this.state) {
   115       case CSS_STATES.property:
   116         return this.completeProperties(this.completing);
   118       case CSS_STATES.value:
   119         return this.completeValues(this.propertyName, this.completing);
   121       case CSS_STATES.selector:
   122         return this.suggestSelectors();
   124       case CSS_STATES.media:
   125       case CSS_STATES.keyframes:
   126         if ("media".startsWith(this.completing)) {
   127           return Promise.resolve([{
   128             label: "media",
   129             preLabel: this.completing,
   130             text: "media"
   131           }]);
   132         } else if ("keyframes".startsWith(this.completing)) {
   133           return Promise.resolve([{
   134             label: "keyframes",
   135             preLabel: this.completing,
   136             text: "keyframes"
   137           }]);
   138         }
   139     }
   140     return Promise.resolve([]);
   141   },
   143   /**
   144    * Resolves the state of CSS at the cursor location. This method implements a
   145    * custom written CSS state machine. The various switch statements provide the
   146    * transition rules for the state. It also finds out various informatino about
   147    * the nearby CSS like the property name being completed, the complete
   148    * selector, etc.
   149    *
   150    * @param source {String} String of the source code.
   151    * @param caret {Object} Cursor location with line and ch properties.
   152    *
   153    * @returns CSS_STATE
   154    *          One of CSS_STATE enum or null if the state cannot be resolved.
   155    */
   156   resolveState: function(source, {line, ch}) {
   157     // Function to return the last element of an array
   158     let peek = arr => arr[arr.length - 1];
   159     // _state can be one of CSS_STATES;
   160     let _state = CSS_STATES.null;
   161     let selector = "";
   162     let selectorState = SELECTOR_STATES.null;
   163     let propertyName = null;
   164     let scopeStack = [];
   165     let selectors = [];
   167     // Fetch the closest null state line, ch from cached null state locations
   168     let matchedStateIndex = this.findNearestNullState(line);
   169     if (matchedStateIndex > -1) {
   170       let state = this.nullStates[matchedStateIndex];
   171       line -= state[0];
   172       if (line == 0)
   173         ch -= state[1];
   174       source = source.split("\n").slice(state[0]);
   175       source[0] = source[0].slice(state[1]);
   176       source = source.join("\n");
   177       scopeStack = [...state[2]];
   178       this.nullStates.length = matchedStateIndex + 1;
   179     }
   180     else {
   181       this.nullStates = [];
   182     }
   183     let tokens = cssTokenizer(source, {loc:true});
   184     let tokIndex = tokens.length - 1;
   185     if (tokens[tokIndex].loc.end.line < line ||
   186        (tokens[tokIndex].loc.end.line === line &&
   187         tokens[tokIndex].loc.end.column < ch)) {
   188       // If the last token is not an EOF, we didn't tokenize it correctly.
   189       // This special case is handled in case we couldn't tokenize, but the last
   190       // token that *could be tokenized* was an identifier.
   191       return null;
   192     }
   193     // Since last token is EOF, the cursor token is last - 1
   194     tokIndex--;
   196     let cursor = 0;
   197     // This will maintain a stack of paired elements like { & }, @m & }, : & ; etc
   198     let token = null;
   199     let selectorBeforeNot = null;
   200     while (cursor <= tokIndex && (token = tokens[cursor++])) {
   201       switch (_state) {
   202         case CSS_STATES.property:
   203           // From CSS_STATES.property, we can either go to CSS_STATES.value state
   204           // when we hit the first ':' or CSS_STATES.selector if "}" is reached.
   205           switch(token.tokenType) {
   206             case ":":
   207               scopeStack.push(":");
   208               if (tokens[cursor - 2].tokenType != "WHITESPACE")
   209                 propertyName = tokens[cursor - 2].value;
   210               else
   211                 propertyName = tokens[cursor - 3].value;
   212               _state = CSS_STATES.value;
   213               break;
   215             case "}":
   216               if (/[{f]/.test(peek(scopeStack))) {
   217                 let popped = scopeStack.pop();
   218                 if (popped == "f") {
   219                   _state = CSS_STATES.frame;
   220                 } else {
   221                   selector = "";
   222                   selectors = [];
   223                   _state = CSS_STATES.null;
   224                 }
   225               }
   226               break;
   227           }
   228           break;
   230         case CSS_STATES.value:
   231           // From CSS_STATES.value, we can go to one of CSS_STATES.property,
   232           // CSS_STATES.frame, CSS_STATES.selector and CSS_STATES.null
   233           switch(token.tokenType) {
   234             case ";":
   235               if (/[:]/.test(peek(scopeStack))) {
   236                 scopeStack.pop();
   237                 _state = CSS_STATES.property;
   238               }
   239               break;
   241             case "}":
   242               if (peek(scopeStack) == ":")
   243                 scopeStack.pop();
   245               if (/[{f]/.test(peek(scopeStack))) {
   246                 let popped = scopeStack.pop();
   247                 if (popped == "f") {
   248                   _state = CSS_STATES.frame;
   249                 } else {
   250                   selector = "";
   251                   selectors = [];
   252                   _state = CSS_STATES.null;
   253                 }
   254               }
   255               break;
   256           }
   257           break;
   259         case CSS_STATES.selector:
   260           // From CSS_STATES.selector, we can only go to CSS_STATES.property when
   261           // we hit "{"
   262           if (token.tokenType == "{") {
   263             scopeStack.push("{");
   264             _state = CSS_STATES.property;
   265             selectors.push(selector);
   266             selector = "";
   267             break;
   268           }
   269           switch(selectorState) {
   270             case SELECTOR_STATES.id:
   271             case SELECTOR_STATES.class:
   272             case SELECTOR_STATES.tag:
   273               switch(token.tokenType) {
   274                 case "HASH":
   275                   selectorState = SELECTOR_STATES.id;
   276                   selector += "#" + token.value;
   277                   break;
   279                 case "DELIM":
   280                   if (token.value == ".") {
   281                     selectorState = SELECTOR_STATES.class;
   282                     selector += ".";
   283                     if (cursor <= tokIndex &&
   284                         tokens[cursor].tokenType == "IDENT") {
   285                       token = tokens[cursor++];
   286                       selector += token.value;
   287                     }
   288                   } else if (token.value == "#") {
   289                     selectorState = SELECTOR_STATES.id;
   290                     selector += "#";
   291                   } else if (/[>~+]/.test(token.value)) {
   292                     selectorState = SELECTOR_STATES.null;
   293                     selector += token.value;
   294                   } else if (token.value == ",") {
   295                     selectorState = SELECTOR_STATES.null;
   296                     selectors.push(selector);
   297                     selector = "";
   298                   }
   299                   break;
   301                 case ":":
   302                   selectorState = SELECTOR_STATES.pseudo;
   303                   selector += ":";
   304                   if (cursor > tokIndex)
   305                     break;
   307                   token = tokens[cursor++];
   308                   switch(token.tokenType) {
   309                     case "FUNCTION":
   310                       if (token.value == "not") {
   311                         selectorBeforeNot = selector;
   312                         selector = "";
   313                         scopeStack.push("(");
   314                       } else {
   315                         selector += token.value + "(";
   316                       }
   317                       selectorState = SELECTOR_STATES.null;
   318                       break;
   320                     case "IDENT":
   321                       selector += token.value;
   322                       break;
   323                   }
   324                   break;
   326                 case "[":
   327                   selectorState = SELECTOR_STATES.attribute;
   328                   scopeStack.push("[");
   329                   selector += "[";
   330                   break;
   332                 case ")":
   333                   if (peek(scopeStack) == "(") {
   334                     scopeStack.pop();
   335                     selector = selectorBeforeNot + "not(" + selector + ")";
   336                     selectorBeforeNot = null;
   337                   } else {
   338                     selector += ")";
   339                   }
   340                   selectorState = SELECTOR_STATES.null;
   341                   break;
   343                 case "WHITESPACE":
   344                   selectorState = SELECTOR_STATES.null;
   345                   selector && (selector += " ");
   346                   break;
   347               }
   348               break;
   350             case SELECTOR_STATES.null:
   351               // From SELECTOR_STATES.null state, we can go to one of
   352               // SELECTOR_STATES.id, SELECTOR_STATES.class or SELECTOR_STATES.tag
   353               switch(token.tokenType) {
   354                 case "HASH":
   355                   selectorState = SELECTOR_STATES.id;
   356                   selector += "#" + token.value;
   357                   break;
   359                 case "IDENT":
   360                   selectorState = SELECTOR_STATES.tag;
   361                   selector += token.value;
   362                   break;
   364                 case "DELIM":
   365                   if (token.value == ".") {
   366                     selectorState = SELECTOR_STATES.class;
   367                     selector += ".";
   368                     if (cursor <= tokIndex &&
   369                         tokens[cursor].tokenType == "IDENT") {
   370                       token = tokens[cursor++];
   371                       selector += token.value;
   372                     }
   373                   } else if (token.value == "#") {
   374                     selectorState = SELECTOR_STATES.id;
   375                     selector += "#";
   376                   } else if (token.value == "*") {
   377                     selectorState = SELECTOR_STATES.tag;
   378                     selector += "*";
   379                   } else if (/[>~+]/.test(token.value)) {
   380                     selector += token.value;
   381                   } else if (token.value == ",") {
   382                     selectorState = SELECTOR_STATES.null;
   383                     selectors.push(selector);
   384                     selector = "";
   385                   }
   386                   break;
   388                 case ":":
   389                   selectorState = SELECTOR_STATES.pseudo;
   390                   selector += ":";
   391                   if (cursor > tokIndex)
   392                     break;
   394                   token = tokens[cursor++];
   395                   switch(token.tokenType) {
   396                     case "FUNCTION":
   397                       if (token.value == "not") {
   398                         selectorBeforeNot = selector;
   399                         selector = "";
   400                         scopeStack.push("(");
   401                       } else {
   402                         selector += token.value + "(";
   403                       }
   404                       selectorState = SELECTOR_STATES.null;
   405                       break;
   407                     case "IDENT":
   408                       selector += token.value;
   409                       break;
   410                   }
   411                   break;
   413                 case "[":
   414                   selectorState = SELECTOR_STATES.attribute;
   415                   scopeStack.push("[");
   416                   selector += "[";
   417                   break;
   419                 case ")":
   420                   if (peek(scopeStack) == "(") {
   421                     scopeStack.pop();
   422                     selector = selectorBeforeNot + "not(" + selector + ")";
   423                     selectorBeforeNot = null;
   424                   } else {
   425                     selector += ")";
   426                   }
   427                   selectorState = SELECTOR_STATES.null;
   428                   break;
   430                 case "WHITESPACE":
   431                   selector && (selector += " ");
   432                   break;
   433               }
   434               break;
   436             case SELECTOR_STATES.pseudo:
   437               switch(token.tokenType) {
   438                 case "DELIM":
   439                   if (/[>~+]/.test(token.value)) {
   440                     selectorState = SELECTOR_STATES.null;
   441                     selector += token.value;
   442                   } else if (token.value == ",") {
   443                     selectorState = SELECTOR_STATES.null;
   444                     selectors.push(selector);
   445                     selector = "";
   446                   }
   447                   break;
   449                 case ":":
   450                   selectorState = SELECTOR_STATES.pseudo;
   451                   selector += ":";
   452                   if (cursor > tokIndex)
   453                     break;
   455                   token = tokens[cursor++];
   456                   switch(token.tokenType) {
   457                     case "FUNCTION":
   458                       if (token.value == "not") {
   459                         selectorBeforeNot = selector;
   460                         selector = "";
   461                         scopeStack.push("(");
   462                       } else {
   463                         selector += token.value + "(";
   464                       }
   465                       selectorState = SELECTOR_STATES.null;
   466                       break;
   468                     case "IDENT":
   469                       selector += token.value;
   470                       break;
   471                   }
   472                   break;
   474                 case "[":
   475                   selectorState = SELECTOR_STATES.attribute;
   476                   scopeStack.push("[");
   477                   selector += "[";
   478                   break;
   480                 case "WHITESPACE":
   481                   selectorState = SELECTOR_STATES.null;
   482                   selector && (selector += " ");
   483                   break;
   484               }
   485               break;
   487             case SELECTOR_STATES.attribute:
   488               switch(token.tokenType) {
   489                 case "DELIM":
   490                   if (/[~|^$*]/.test(token.value)) {
   491                     selector += token.value;
   492                     token = tokens[cursor++];
   493                   }
   494                   if(token.value == "=") {
   495                     selectorState = SELECTOR_STATES.value;
   496                     selector += token.value;
   497                   }
   498                   break;
   500                 case "IDENT":
   501                 case "STRING":
   502                   selector += token.value;
   503                   break;
   505                 case "]":
   506                   if (peek(scopeStack) == "[")
   507                     scopeStack.pop();
   509                   selectorState = SELECTOR_STATES.null;
   510                   selector += "]";
   511                   break;
   513                 case "WHITESPACE":
   514                   selector && (selector += " ");
   515                   break;
   516               }
   517               break;
   519             case SELECTOR_STATES.value:
   520               switch(token.tokenType) {
   521                 case "STRING":
   522                 case "IDENT":
   523                   selector += token.value;
   524                   break;
   526                 case "]":
   527                   if (peek(scopeStack) == "[")
   528                     scopeStack.pop();
   530                   selectorState = SELECTOR_STATES.null;
   531                   selector += "]";
   532                   break;
   534                 case "WHITESPACE":
   535                   selector && (selector += " ");
   536                   break;
   537               }
   538               break;
   539           }
   540           break;
   542         case CSS_STATES.null:
   543           // From CSS_STATES.null state, we can go to either CSS_STATES.media or
   544           // CSS_STATES.selector.
   545           switch(token.tokenType) {
   546             case "HASH":
   547               selectorState = SELECTOR_STATES.id;
   548               selector = "#" + token.value;
   549               _state = CSS_STATES.selector;
   550               break;
   552             case "IDENT":
   553               selectorState = SELECTOR_STATES.tag;
   554               selector = token.value;
   555               _state = CSS_STATES.selector;
   556               break;
   558             case "DELIM":
   559               if (token.value == ".") {
   560                 selectorState = SELECTOR_STATES.class;
   561                 selector = ".";
   562                 _state = CSS_STATES.selector;
   563                 if (cursor <= tokIndex &&
   564                     tokens[cursor].tokenType == "IDENT") {
   565                   token = tokens[cursor++];
   566                   selector += token.value;
   567                 }
   568               } else if (token.value == "#") {
   569                 selectorState = SELECTOR_STATES.id;
   570                 selector = "#";
   571                 _state = CSS_STATES.selector;
   572               } else if (token.value == "*") {
   573                 selectorState = SELECTOR_STATES.tag;
   574                 selector = "*";
   575                 _state = CSS_STATES.selector;
   576               }
   577               break;
   579             case ":":
   580               _state = CSS_STATES.selector;
   581               selectorState = SELECTOR_STATES.pseudo;
   582               selector += ":";
   583               if (cursor > tokIndex)
   584                 break;
   586               token = tokens[cursor++];
   587               switch(token.tokenType) {
   588                 case "FUNCTION":
   589                   if (token.value == "not") {
   590                     selectorBeforeNot = selector;
   591                     selector = "";
   592                     scopeStack.push("(");
   593                   } else {
   594                     selector += token.value + "(";
   595                   }
   596                   selectorState = SELECTOR_STATES.null;
   597                   break;
   599                 case "IDENT":
   600                   selector += token.value;
   601                   break;
   602               }
   603               break;
   605             case "[":
   606               _state = CSS_STATES.selector;
   607               selectorState = SELECTOR_STATES.attribute;
   608               scopeStack.push("[");
   609               selector += "[";
   610               break;
   612             case "AT-KEYWORD":
   613               _state = token.value.startsWith("m") ? CSS_STATES.media
   614                                                    : CSS_STATES.keyframes;
   615               break;
   617             case "}":
   618               if (peek(scopeStack) == "@m")
   619                 scopeStack.pop();
   621               break;
   622           }
   623           break;
   625         case CSS_STATES.media:
   626           // From CSS_STATES.media, we can only go to CSS_STATES.null state when
   627           // we hit the first '{'
   628           if (token.tokenType == "{") {
   629             scopeStack.push("@m");
   630             _state = CSS_STATES.null;
   631           }
   632           break;
   634         case CSS_STATES.keyframes:
   635           // From CSS_STATES.keyframes, we can only go to CSS_STATES.frame state
   636           // when we hit the first '{'
   637           if (token.tokenType == "{") {
   638             scopeStack.push("@k");
   639             _state = CSS_STATES.frame;
   640           }
   641           break;
   643         case CSS_STATES.frame:
   644           // From CSS_STATES.frame, we can either go to CSS_STATES.property state
   645           // when we hit the first '{' or to CSS_STATES.selector when we hit '}'
   646           if (token.tokenType == "{") {
   647             scopeStack.push("f");
   648             _state = CSS_STATES.property;
   649           } else if (token.tokenType == "}") {
   650             if (peek(scopeStack) == "@k")
   651               scopeStack.pop();
   653             _state = CSS_STATES.null;
   654           }
   655           break;
   656       }
   657       if (_state == CSS_STATES.null) {
   658         if (this.nullStates.length == 0) {
   659           this.nullStates.push([token.loc.end.line, token.loc.end.column,
   660                                 [...scopeStack]]);
   661           continue;
   662         }
   663         let tokenLine = token.loc.end.line;
   664         let tokenCh = token.loc.end.column;
   665         if (tokenLine == 0)
   666           continue;
   667         if (matchedStateIndex > -1)
   668           tokenLine += this.nullStates[matchedStateIndex][0];
   669         this.nullStates.push([tokenLine, tokenCh, [...scopeStack]]);
   670       }
   671     }
   672     this.state = _state;
   673     this.propertyName = _state == CSS_STATES.value ? propertyName : null;
   674     this.selectorState = _state == CSS_STATES.selector ? selectorState : null;
   675     this.selectorBeforeNot = selectorBeforeNot == null ? null: selectorBeforeNot;
   676     if (token) {
   677       selector = selector.slice(0, selector.length + token.loc.end.column - ch);
   678       this.selector = selector;
   679     }
   680     else {
   681       this.selector = "";
   682     }
   683     this.selectors = selectors;
   685     if (token && token.tokenType != "WHITESPACE") {
   686       this.completing = ((token.value || token.repr || token.tokenType) + "")
   687                           .slice(0, ch - token.loc.start.column)
   688                           .replace(/^[.#]$/, "");
   689     } else {
   690       this.completing = "";
   691     }
   692     // Special case the situation when the user just entered ":" after typing a
   693     // property name.
   694     if (this.completing == ":" && _state == CSS_STATES.value)
   695       this.completing = "";
   697     // Special check for !important; case.
   698     if (token && tokens[cursor - 2] && tokens[cursor - 2].value == "!" &&
   699         this.completing == "important".slice(0, this.completing.length)) {
   700       this.completing = "!" + this.completing;
   701     }
   702     return _state;
   703   },
   705   /**
   706    * Queries the DOM Walker actor for suggestions regarding the selector being
   707    * completed
   708    */
   709   suggestSelectors: function () {
   710     let walker = this.walker;
   711     if (!walker)
   712       return Promise.resolve([]);
   714     let query = this.selector;
   715     // Even though the selector matched atleast one node, there is still
   716     // possibility of suggestions.
   717     switch(this.selectorState) {
   718       case SELECTOR_STATES.null:
   719         query += "*";
   720         break;
   722       case SELECTOR_STATES.tag:
   723         query = query.slice(0, query.length - this.completing.length);
   724         break;
   726       case SELECTOR_STATES.id:
   727       case SELECTOR_STATES.class:
   728       case SELECTOR_STATES.pseudo:
   729         if (/^[.:#]$/.test(this.completing)) {
   730           query = query.slice(0, query.length - this.completing.length);
   731           this.completing = "";
   732         } else {
   733           query = query.slice(0, query.length - this.completing.length - 1);
   734         }
   735         break;
   736     }
   738     if (/[\s+>~]$/.test(query) &&
   739         this.selectorState != SELECTOR_STATES.attribute &&
   740         this.selectorState != SELECTOR_STATES.value) {
   741       query += "*";
   742     }
   744     // Set the values that this request was supposed to suggest to.
   745     this._currentQuery = query;
   746     return walker.getSuggestionsForQuery(query, this.completing, this.selectorState)
   747                  .then(result => this.prepareSelectorResults(result));
   748   },
   750  /**
   751   * Prepares the selector suggestions returned by the walker actor.
   752   */
   753   prepareSelectorResults: function(result) {
   754     if (this._currentQuery != result.query)
   755       return [];
   757     result = result.suggestions;
   758     let query = this.selector;
   759     let completion = [];
   760     for (let value of result) {
   761       switch(this.selectorState) {
   762         case SELECTOR_STATES.id:
   763         case SELECTOR_STATES.class:
   764         case SELECTOR_STATES.pseudo:
   765           if (/^[.:#]$/.test(this.completing)) {
   766             value[0] = query.slice(0, query.length - this.completing.length) +
   767                        value[0];
   768           } else {
   769             value[0] = query.slice(0, query.length - this.completing.length - 1) +
   770                        value[0];
   771           }
   772           break;
   774         case SELECTOR_STATES.tag:
   775           value[0] = query.slice(0, query.length - this.completing.length) +
   776                      value[0];
   777           break;
   779         case SELECTOR_STATES.null:
   780           value[0] = query + value[0];
   781           break;
   783         default:
   784          value[0] = query.slice(0, query.length - this.completing.length) +
   785                     value[0];
   786       }
   787       completion.push({
   788         label: value[0],
   789         preLabel: query,
   790         text: value[0],
   791         score: value[1]
   792       });
   793       if (completion.length > this.maxEntries - 1)
   794         break;
   795     }
   796     return completion;
   797   },
   799   /**
   800    * Returns CSS property name suggestions based on the input.
   801    *
   802    * @param startProp {String} Initial part of the property being completed.
   803    */
   804   completeProperties: function(startProp) {
   805     let finalList = [];
   806     if (!startProp)
   807       return Promise.resolve(finalList);
   809     let length = propertyNames.length;
   810     let i = 0, count = 0;
   811     for (; i < length && count < this.maxEntries; i++) {
   812       if (propertyNames[i].startsWith(startProp)) {
   813         count++;
   814         let propName = propertyNames[i];
   815         finalList.push({
   816           preLabel: startProp,
   817           label: propName,
   818           text: propName + ": "
   819         });
   820       } else if (propertyNames[i] > startProp) {
   821         // We have crossed all possible matches alphabetically.
   822         break;
   823       }
   824     }
   825     return Promise.resolve(finalList);
   826   },
   828   /**
   829    * Returns CSS value suggestions based on the corresponding property.
   830    *
   831    * @param propName {String} The property to which the value being completed
   832    *        belongs.
   833    * @param startValue {String} Initial part of the value being completed.
   834    */
   835   completeValues: function(propName, startValue) {
   836     let finalList = [];
   837     let list = ["!important;", ...(properties[propName] || [])];
   838     // If there is no character being completed, we are showing an initial list
   839     // of possible values. Skipping '!important' in this case.
   840     if (!startValue)
   841       list.splice(0, 1);
   843     let length = list.length;
   844     let i = 0, count = 0;
   845     for (; i < length && count < this.maxEntries; i++) {
   846       if (list[i].startsWith(startValue)) {
   847         count++;
   848         let value = list[i];
   849         finalList.push({
   850           preLabel: startValue,
   851           label: value,
   852           text: value
   853         });
   854       } else if (list[i] > startValue) {
   855         // We have crossed all possible matches alphabetically.
   856         break;
   857       }
   858     }
   859     return Promise.resolve(finalList);
   860   },
   862   /**
   863    * A biased binary search in a sorted array where the middle element is
   864    * calculated based on the values at the lower and the upper index in each
   865    * iteration.
   866    *
   867    * This method returns the index of the closest null state from the passed
   868    * `line` argument. Once we have the closest null state, we can start applying
   869    * the state machine logic from that location instead of the absolute starting
   870    * of the CSS source. This speeds up the tokenizing and the state machine a
   871    * lot while using autocompletion at high line numbers in a CSS source.
   872    */
   873   findNearestNullState: function(line) {
   874     let arr = this.nullStates;
   875     let high = arr.length - 1;
   876     let low = 0;
   877     let target = 0;
   879     if (high < 0)
   880       return -1;
   881     if (arr[high][0] <= line)
   882       return high;
   883     if (arr[low][0] > line)
   884       return -1;
   886     while (high > low) {
   887       if (arr[low][0] <= line && arr[low [0]+ 1] > line)
   888         return low;
   889       if (arr[high][0] > line && arr[high - 1][0] <= line)
   890         return high - 1;
   892       target = (((line - arr[low][0]) / (arr[high][0] - arr[low][0])) *
   893                 (high - low)) | 0;
   895       if (arr[target][0] <= line && arr[target + 1][0] > line) {
   896         return target;
   897       } else if (line > arr[target][0]) {
   898         low = target + 1;
   899         high--;
   900       } else {
   901         high = target - 1;
   902         low++;
   903       }
   904     }
   906     return -1;
   907   },
   909   /**
   910    * Invalidates the state cache for and above the line.
   911    */
   912   invalidateCache: function(line) {
   913     this.nullStates.length = this.findNearestNullState(line) + 1;
   914   },
   916   /**
   917    * Get the state information about a token surrounding the {line, ch} position
   918    *
   919    * @param {string} source
   920    *        The complete source of the CSS file. Unlike resolve state method,
   921    *        this method requires the full source.
   922    * @param {object} caret
   923    *        The line, ch position of the caret.
   924    *
   925    * @returns {object}
   926    *          An object containing the state of token covered by the caret.
   927    *          The object has following properties when the the state is
   928    *          "selector", "value" or "property", null otherwise:
   929    *           - state {string} one of CSS_STATES - "selector", "value" etc.
   930    *           - selector {string} The selector at the caret when `state` is
   931    *                      selector. OR
   932    *           - selectors {[string]} Array of selector strings in case when
   933    *                       `state` is "value" or "property"
   934    *           - propertyName {string} The property name at the current caret or
   935    *                          the property name corresponding to the value at
   936    *                          the caret.
   937    *           - value {string} The css value at the current caret.
   938    *           - loc {object} An object containing the starting and the ending
   939    *                 caret position of the whole selector, value or property.
   940    *                  - { start: {line, ch}, end: {line, ch}}
   941    */
   942   getInfoAt: function(source, caret) {
   943     // Limits the input source till the {line, ch} caret position
   944     function limit(source, {line, ch}) {
   945       line++;
   946       let list = source.split("\n");
   947       if (list.length < line)
   948         return source;
   949       if (line == 1)
   950         return list[0].slice(0, ch);
   951       return [...list.slice(0, line - 1), list[line - 1].slice(0, ch)].join("\n");
   952     }
   954     // Get the state at the given line, ch
   955     let state = this.resolveState(limit(source, caret), caret);
   956     let propertyName = this.propertyName;
   957     let {line, ch} = caret;
   958     let sourceArray = source.split("\n");
   959     let limitedSource = limit(source, caret);
   961     /**
   962      * Method to traverse forwards from the caret location to figure out the
   963      * ending point of a selector or css value.
   964      *
   965      * @param {function} check
   966      *        A method which takes the current state as an input and determines
   967      *        whether the state changed or not.
   968      */
   969     let traverseForward = check => {
   970       let location;
   971       // Backward loop to determine the beginning location of the selector.
   972       do {
   973         let lineText = sourceArray[line];
   974         if (line == caret.line)
   975           lineText = lineText.substring(caret.ch);
   977         let tokens = cssTokenizer(lineText, {loc: true});
   978         let found = false;
   979         let ech = line == caret.line ? caret.ch : 0;
   980         for (let i = 0; i < tokens.length; i++) {
   981           let token = tokens[i];
   982           // If the line is completely spaces, handle it differently
   983           if (lineText.trim() == "") {
   984             limitedSource += lineText;
   985           } else {
   986             limitedSource += sourceArray[line]
   987                               .substring(ech + token.loc.start.column,
   988                                          ech + token.loc.end.column);
   989           }
   991           // Whitespace cannot change state.
   992           if (token.tokenType == "WHITESPACE")
   993             continue;
   995           let state = this.resolveState(limitedSource, {
   996             line: line,
   997             ch: token.loc.end.column + ech
   998           });
   999           if (check(state)) {
  1000             if (tokens[i - 1] && tokens[i - 1].tokenType == "WHITESPACE")
  1001               token = tokens[i - 1];
  1002             location = {
  1003               line: line,
  1004               ch: token.loc.start.column + ech
  1005             };
  1006             found = true;
  1007             break;
  1010         limitedSource += "\n";
  1011         if (found)
  1012           break;
  1013       } while (line++ < sourceArray.length);
  1014       return location;
  1015     };
  1017     /**
  1018      * Method to traverse backwards from the caret location to figure out the
  1019      * starting point of a selector or css value.
  1021      * @param {function} check
  1022      *        A method which takes the current state as an input and determines
  1023      *        whether the state changed or not.
  1024      * @param {boolean} isValue
  1025      *        true if the traversal is being done for a css value state.
  1026      */
  1027     let traverseBackwards = (check, isValue) => {
  1028       let location;
  1029       // Backward loop to determine the beginning location of the selector.
  1030       do {
  1031         let lineText = sourceArray[line];
  1032         if (line == caret.line)
  1033           lineText = lineText.substring(0, caret.ch);
  1035         let tokens = cssTokenizer(lineText, {loc: true});
  1036         let found = false;
  1037         let ech = 0;
  1038         for (let i = tokens.length - 2; i >= 0; i--) {
  1039           let token = tokens[i];
  1040           // If the line is completely spaces, handle it differently
  1041           if (lineText.trim() == "") {
  1042             limitedSource = limitedSource.slice(0, -1 * lineText.length);
  1043           } else {
  1044             let length = token.loc.end.column - token.loc.start.column;
  1045             limitedSource = limitedSource.slice(0, -1 * length);
  1048           // Whitespace cannot change state.
  1049           if (token.tokenType == "WHITESPACE")
  1050             continue;
  1052           let state = this.resolveState(limitedSource, {
  1053             line: line,
  1054             ch: token.loc.start.column
  1055           });
  1056           if (check(state)) {
  1057             if (tokens[i + 1] && tokens[i + 1].tokenType == "WHITESPACE")
  1058               token = tokens[i + 1];
  1059             location = {
  1060               line: line,
  1061               ch: isValue ? token.loc.end.column: token.loc.start.column
  1062             };
  1063             found = true;
  1064             break;
  1067         limitedSource = limitedSource.slice(0, -1);
  1068         if (found)
  1069           break;
  1070       } while (line-- >= 0);
  1071       return location;
  1072     };
  1074     if (state == CSS_STATES.selector) {
  1075       // For selector state, the ending and starting point of the selector is
  1076       // either when the state changes or the selector becomes empty and a
  1077       // single selector can span multiple lines.
  1078       // Backward loop to determine the beginning location of the selector.
  1079       let start = traverseBackwards(state => {
  1080         return (state != CSS_STATES.selector ||
  1081                (this.selector == "" && this.selectorBeforeNot == null));
  1082       });
  1084       line = caret.line;
  1085       limitedSource = limit(source, caret);
  1086       // Forward loop to determine the ending location of the selector.
  1087       let end = traverseForward(state => {
  1088         return (state != CSS_STATES.selector ||
  1089                (this.selector == "" && this.selectorBeforeNot == null));
  1090       });
  1092       // Since we have start and end positions, figure out the whole selector.
  1093       let selector = source.split("\n").slice(start.line, end.line + 1);
  1094       selector[selector.length - 1] =
  1095         selector[selector.length - 1].substring(0, end.ch);
  1096       selector[0] = selector[0].substring(start.ch);
  1097       selector = selector.join("\n");
  1098       return {
  1099         state: state,
  1100         selector: selector,
  1101         loc: {
  1102           start: start,
  1103           end: end
  1105       };
  1107     else if (state == CSS_STATES.property) {
  1108       // A property can only be a single word and thus very easy to calculate.
  1109       let tokens = cssTokenizer(sourceArray[line], {loc: true});
  1110       for (let token of tokens) {
  1111         if (token.loc.start.column <= ch && token.loc.end.column >= ch) {
  1112           return {
  1113             state: state,
  1114             propertyName: token.value,
  1115             selectors: this.selectors,
  1116             loc: {
  1117               start: {
  1118                 line: line,
  1119                 ch: token.loc.start.column
  1120               },
  1121               end: {
  1122                 line: line,
  1123                 ch: token.loc.end.column
  1126           };
  1130     else if (state == CSS_STATES.value) {
  1131       // CSS value can be multiline too, so we go forward and backwards to
  1132       // determine the bounds of the value at caret
  1133       let start = traverseBackwards(state => state != CSS_STATES.value, true);
  1135       line = caret.line;
  1136       limitedSource = limit(source, caret);
  1137       let end = traverseForward(state => state != CSS_STATES.value);
  1139       let value = source.split("\n").slice(start.line, end.line + 1);
  1140       value[value.length - 1] = value[value.length - 1].substring(0, end.ch);
  1141       value[0] = value[0].substring(start.ch);
  1142       value = value.join("\n");
  1143       return {
  1144         state: state,
  1145         propertyName: propertyName,
  1146         selectors: this.selectors,
  1147         value: value,
  1148         loc: {
  1149           start: start,
  1150           end: end
  1152       };
  1154     return null;
  1158 /**
  1159  * Returns a list of all property names and a map of property name vs possible
  1160  * CSS values provided by the Gecko engine.
  1162  * @return {Object} An object with following properties:
  1163  *         - propertyNames {Array} Array of string containing all the possible
  1164  *                         CSS property names.
  1165  *         - properties {Object|Map} A map where key is the property name and
  1166  *                      value is an array of string containing all the possible
  1167  *                      CSS values the property can have.
  1168  */
  1169 function getCSSKeywords() {
  1170   let domUtils = Cc["@mozilla.org/inspector/dom-utils;1"]
  1171                    .getService(Ci.inIDOMUtils);
  1172   let props = {};
  1173   let propNames = domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES);
  1174   propNames.forEach(prop => {
  1175     props[prop] = domUtils.getCSSValuesForProperty(prop).sort();
  1176   });
  1177   return {
  1178     properties: props,
  1179     propertyNames: propNames.sort()
  1180   };
  1183 module.exports = CSSCompleter;

mercurial