browser/devtools/sourceeditor/css-autocompleter.js

branch
TOR_BUG_9701
changeset 15
b8a032363ba2
equal deleted inserted replaced
-1:000000000000 0:9fc533cf5de2
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 const { Cc, Ci, Cu } = require('chrome');
6 const cssTokenizer = require("devtools/sourceeditor/css-tokenizer");
7 const promise = Cu.import("resource://gre/modules/Promise.jsm");
8
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 */
51
52 // Autocompletion types.
53
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 };
63
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 };
73
74 const { properties, propertyNames } = getCSSKeywords();
75
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;
87
88 // Array containing the [line, ch, scopeStack] for the locations where the
89 // CSS state is "null"
90 this.nullStates = [];
91 }
92
93 CSSCompleter.prototype = {
94
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 }
112
113 // Properly suggest based on the state.
114 switch(this.state) {
115 case CSS_STATES.property:
116 return this.completeProperties(this.completing);
117
118 case CSS_STATES.value:
119 return this.completeValues(this.propertyName, this.completing);
120
121 case CSS_STATES.selector:
122 return this.suggestSelectors();
123
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 },
142
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 = [];
166
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--;
195
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;
214
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;
229
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;
240
241 case "}":
242 if (peek(scopeStack) == ":")
243 scopeStack.pop();
244
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;
258
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;
278
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;
300
301 case ":":
302 selectorState = SELECTOR_STATES.pseudo;
303 selector += ":";
304 if (cursor > tokIndex)
305 break;
306
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;
319
320 case "IDENT":
321 selector += token.value;
322 break;
323 }
324 break;
325
326 case "[":
327 selectorState = SELECTOR_STATES.attribute;
328 scopeStack.push("[");
329 selector += "[";
330 break;
331
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;
342
343 case "WHITESPACE":
344 selectorState = SELECTOR_STATES.null;
345 selector && (selector += " ");
346 break;
347 }
348 break;
349
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;
358
359 case "IDENT":
360 selectorState = SELECTOR_STATES.tag;
361 selector += token.value;
362 break;
363
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;
387
388 case ":":
389 selectorState = SELECTOR_STATES.pseudo;
390 selector += ":";
391 if (cursor > tokIndex)
392 break;
393
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;
406
407 case "IDENT":
408 selector += token.value;
409 break;
410 }
411 break;
412
413 case "[":
414 selectorState = SELECTOR_STATES.attribute;
415 scopeStack.push("[");
416 selector += "[";
417 break;
418
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;
429
430 case "WHITESPACE":
431 selector && (selector += " ");
432 break;
433 }
434 break;
435
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;
448
449 case ":":
450 selectorState = SELECTOR_STATES.pseudo;
451 selector += ":";
452 if (cursor > tokIndex)
453 break;
454
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;
467
468 case "IDENT":
469 selector += token.value;
470 break;
471 }
472 break;
473
474 case "[":
475 selectorState = SELECTOR_STATES.attribute;
476 scopeStack.push("[");
477 selector += "[";
478 break;
479
480 case "WHITESPACE":
481 selectorState = SELECTOR_STATES.null;
482 selector && (selector += " ");
483 break;
484 }
485 break;
486
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;
499
500 case "IDENT":
501 case "STRING":
502 selector += token.value;
503 break;
504
505 case "]":
506 if (peek(scopeStack) == "[")
507 scopeStack.pop();
508
509 selectorState = SELECTOR_STATES.null;
510 selector += "]";
511 break;
512
513 case "WHITESPACE":
514 selector && (selector += " ");
515 break;
516 }
517 break;
518
519 case SELECTOR_STATES.value:
520 switch(token.tokenType) {
521 case "STRING":
522 case "IDENT":
523 selector += token.value;
524 break;
525
526 case "]":
527 if (peek(scopeStack) == "[")
528 scopeStack.pop();
529
530 selectorState = SELECTOR_STATES.null;
531 selector += "]";
532 break;
533
534 case "WHITESPACE":
535 selector && (selector += " ");
536 break;
537 }
538 break;
539 }
540 break;
541
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;
551
552 case "IDENT":
553 selectorState = SELECTOR_STATES.tag;
554 selector = token.value;
555 _state = CSS_STATES.selector;
556 break;
557
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;
578
579 case ":":
580 _state = CSS_STATES.selector;
581 selectorState = SELECTOR_STATES.pseudo;
582 selector += ":";
583 if (cursor > tokIndex)
584 break;
585
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;
598
599 case "IDENT":
600 selector += token.value;
601 break;
602 }
603 break;
604
605 case "[":
606 _state = CSS_STATES.selector;
607 selectorState = SELECTOR_STATES.attribute;
608 scopeStack.push("[");
609 selector += "[";
610 break;
611
612 case "AT-KEYWORD":
613 _state = token.value.startsWith("m") ? CSS_STATES.media
614 : CSS_STATES.keyframes;
615 break;
616
617 case "}":
618 if (peek(scopeStack) == "@m")
619 scopeStack.pop();
620
621 break;
622 }
623 break;
624
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;
633
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;
642
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();
652
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;
684
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 = "";
696
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 },
704
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([]);
713
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;
721
722 case SELECTOR_STATES.tag:
723 query = query.slice(0, query.length - this.completing.length);
724 break;
725
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 }
737
738 if (/[\s+>~]$/.test(query) &&
739 this.selectorState != SELECTOR_STATES.attribute &&
740 this.selectorState != SELECTOR_STATES.value) {
741 query += "*";
742 }
743
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 },
749
750 /**
751 * Prepares the selector suggestions returned by the walker actor.
752 */
753 prepareSelectorResults: function(result) {
754 if (this._currentQuery != result.query)
755 return [];
756
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;
773
774 case SELECTOR_STATES.tag:
775 value[0] = query.slice(0, query.length - this.completing.length) +
776 value[0];
777 break;
778
779 case SELECTOR_STATES.null:
780 value[0] = query + value[0];
781 break;
782
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 },
798
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);
808
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 },
827
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);
842
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 },
861
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;
878
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;
885
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;
891
892 target = (((line - arr[low][0]) / (arr[high][0] - arr[low][0])) *
893 (high - low)) | 0;
894
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 }
905
906 return -1;
907 },
908
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 },
915
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 }
953
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);
960
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);
976
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 }
990
991 // Whitespace cannot change state.
992 if (token.tokenType == "WHITESPACE")
993 continue;
994
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;
1008 }
1009 }
1010 limitedSource += "\n";
1011 if (found)
1012 break;
1013 } while (line++ < sourceArray.length);
1014 return location;
1015 };
1016
1017 /**
1018 * Method to traverse backwards from the caret location to figure out the
1019 * starting point of a selector or css value.
1020 *
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);
1034
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);
1046 }
1047
1048 // Whitespace cannot change state.
1049 if (token.tokenType == "WHITESPACE")
1050 continue;
1051
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;
1065 }
1066 }
1067 limitedSource = limitedSource.slice(0, -1);
1068 if (found)
1069 break;
1070 } while (line-- >= 0);
1071 return location;
1072 };
1073
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 });
1083
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 });
1091
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
1104 }
1105 };
1106 }
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
1124 }
1125 }
1126 };
1127 }
1128 }
1129 }
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);
1134
1135 line = caret.line;
1136 limitedSource = limit(source, caret);
1137 let end = traverseForward(state => state != CSS_STATES.value);
1138
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
1151 }
1152 };
1153 }
1154 return null;
1155 }
1156 }
1157
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.
1161 *
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 };
1181 }
1182
1183 module.exports = CSSCompleter;

mercurial