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;