|
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; |