michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: const { Cc, Ci, Cu } = require('chrome'); michael@0: const cssTokenizer = require("devtools/sourceeditor/css-tokenizer"); michael@0: const promise = Cu.import("resource://gre/modules/Promise.jsm"); michael@0: michael@0: /** michael@0: * Here is what this file (+ ./css-tokenizer.js) do. michael@0: * michael@0: * The main objective here is to provide as much suggestions to the user editing michael@0: * a stylesheet in Style Editor. The possible things that can be suggested are: michael@0: * - CSS property names michael@0: * - CSS property values michael@0: * - CSS Selectors michael@0: * - Some other known CSS keywords michael@0: * michael@0: * Gecko provides a list of both property names and their corresponding values. michael@0: * We take out a list of matching selectors using the Inspector actor's michael@0: * `getSuggestionsForQuery` method. Now the only thing is to parse the CSS being michael@0: * edited by the user, figure out what token or word is being written and last michael@0: * but the most difficult, what is being edited. michael@0: * michael@0: * The file 'css-tokenizer' helps in converting the CSS into meaningful tokens, michael@0: * each having a certain type associated with it. These tokens help us to figure michael@0: * out the currently edited word and to write a CSS state machine to figure out michael@0: * what the user is currently editing. By that, I mean, whether he is editing a michael@0: * selector or a property or a value, or even fine grained information like an michael@0: * id in the selector. michael@0: * michael@0: * The `resolveState` method iterated over the tokens spitted out by the michael@0: * tokenizer, using switch cases, follows a state machine logic and finally michael@0: * figures out these informations: michael@0: * - The state of the CSS at the cursor (one out of CSS_STATES) michael@0: * - The current token that is being edited `cmpleting` michael@0: * - If the state is "selector", the selector state (one of SELECTOR_STATES) michael@0: * - If the state is "selector", the current selector till the cursor michael@0: * - If the state is "value", the corresponding property name michael@0: * michael@0: * In case of "value" and "property" states, we simply use the information michael@0: * provided by Gecko to filter out the possible suggestions. michael@0: * For "selector" state, we request the Inspector actor to query the page DOM michael@0: * and filter out the possible suggestions. michael@0: * For "media" and "keyframes" state, the only possible suggestions for now are michael@0: * "media" and "keyframes" respectively, although "media" can have suggestions michael@0: * like "max-width", "orientation" etc. Similarly "value" state can also have michael@0: * much better logical suggestions if we fine grain identify a sub state just michael@0: * like we do for the "selector" state. michael@0: */ michael@0: michael@0: // Autocompletion types. michael@0: michael@0: const CSS_STATES = { michael@0: "null": "null", michael@0: property: "property", // foo { bar|: … } michael@0: value: "value", // foo {bar: baz|} michael@0: selector: "selector", // f| {bar: baz} michael@0: media: "media", // @med| , or , @media scr| { } michael@0: keyframes: "keyframes", // @keyf| michael@0: frame: "frame", // @keyframs foobar { t| michael@0: }; michael@0: michael@0: const SELECTOR_STATES = { michael@0: "null": "null", michael@0: id: "id", // #f| michael@0: class: "class", // #foo.b| michael@0: tag: "tag", // fo| michael@0: pseudo: "pseudo", // foo:| michael@0: attribute: "attribute", // foo[b| michael@0: value: "value", // foo[bar=b| michael@0: }; michael@0: michael@0: const { properties, propertyNames } = getCSSKeywords(); michael@0: michael@0: /** michael@0: * Constructor for the autocompletion object. michael@0: * michael@0: * @param options {Object} An options object containing the following options: michael@0: * - walker {Object} The object used for query selecting from the current michael@0: * target's DOM. michael@0: * - maxEntries {Number} Maximum selectors suggestions to display. michael@0: */ michael@0: function CSSCompleter(options = {}) { michael@0: this.walker = options.walker; michael@0: this.maxEntries = options.maxEntries || 15; michael@0: michael@0: // Array containing the [line, ch, scopeStack] for the locations where the michael@0: // CSS state is "null" michael@0: this.nullStates = []; michael@0: } michael@0: michael@0: CSSCompleter.prototype = { michael@0: michael@0: /** michael@0: * Returns a list of suggestions based on the caret position. michael@0: * michael@0: * @param source {String} String of the source code. michael@0: * @param caret {Object} Cursor location with line and ch properties. michael@0: * michael@0: * @returns [{object}] A sorted list of objects containing the following michael@0: * peroperties: michael@0: * - label {String} Full keyword for the suggestion michael@0: * - preLabel {String} Already entered part of the label michael@0: */ michael@0: complete: function(source, caret) { michael@0: // Getting the context from the caret position. michael@0: if (!this.resolveState(source, caret)) { michael@0: // We couldn't resolve the context, we won't be able to complete. michael@0: return Promise.resolve([]); michael@0: } michael@0: michael@0: // Properly suggest based on the state. michael@0: switch(this.state) { michael@0: case CSS_STATES.property: michael@0: return this.completeProperties(this.completing); michael@0: michael@0: case CSS_STATES.value: michael@0: return this.completeValues(this.propertyName, this.completing); michael@0: michael@0: case CSS_STATES.selector: michael@0: return this.suggestSelectors(); michael@0: michael@0: case CSS_STATES.media: michael@0: case CSS_STATES.keyframes: michael@0: if ("media".startsWith(this.completing)) { michael@0: return Promise.resolve([{ michael@0: label: "media", michael@0: preLabel: this.completing, michael@0: text: "media" michael@0: }]); michael@0: } else if ("keyframes".startsWith(this.completing)) { michael@0: return Promise.resolve([{ michael@0: label: "keyframes", michael@0: preLabel: this.completing, michael@0: text: "keyframes" michael@0: }]); michael@0: } michael@0: } michael@0: return Promise.resolve([]); michael@0: }, michael@0: michael@0: /** michael@0: * Resolves the state of CSS at the cursor location. This method implements a michael@0: * custom written CSS state machine. The various switch statements provide the michael@0: * transition rules for the state. It also finds out various informatino about michael@0: * the nearby CSS like the property name being completed, the complete michael@0: * selector, etc. michael@0: * michael@0: * @param source {String} String of the source code. michael@0: * @param caret {Object} Cursor location with line and ch properties. michael@0: * michael@0: * @returns CSS_STATE michael@0: * One of CSS_STATE enum or null if the state cannot be resolved. michael@0: */ michael@0: resolveState: function(source, {line, ch}) { michael@0: // Function to return the last element of an array michael@0: let peek = arr => arr[arr.length - 1]; michael@0: // _state can be one of CSS_STATES; michael@0: let _state = CSS_STATES.null; michael@0: let selector = ""; michael@0: let selectorState = SELECTOR_STATES.null; michael@0: let propertyName = null; michael@0: let scopeStack = []; michael@0: let selectors = []; michael@0: michael@0: // Fetch the closest null state line, ch from cached null state locations michael@0: let matchedStateIndex = this.findNearestNullState(line); michael@0: if (matchedStateIndex > -1) { michael@0: let state = this.nullStates[matchedStateIndex]; michael@0: line -= state[0]; michael@0: if (line == 0) michael@0: ch -= state[1]; michael@0: source = source.split("\n").slice(state[0]); michael@0: source[0] = source[0].slice(state[1]); michael@0: source = source.join("\n"); michael@0: scopeStack = [...state[2]]; michael@0: this.nullStates.length = matchedStateIndex + 1; michael@0: } michael@0: else { michael@0: this.nullStates = []; michael@0: } michael@0: let tokens = cssTokenizer(source, {loc:true}); michael@0: let tokIndex = tokens.length - 1; michael@0: if (tokens[tokIndex].loc.end.line < line || michael@0: (tokens[tokIndex].loc.end.line === line && michael@0: tokens[tokIndex].loc.end.column < ch)) { michael@0: // If the last token is not an EOF, we didn't tokenize it correctly. michael@0: // This special case is handled in case we couldn't tokenize, but the last michael@0: // token that *could be tokenized* was an identifier. michael@0: return null; michael@0: } michael@0: // Since last token is EOF, the cursor token is last - 1 michael@0: tokIndex--; michael@0: michael@0: let cursor = 0; michael@0: // This will maintain a stack of paired elements like { & }, @m & }, : & ; etc michael@0: let token = null; michael@0: let selectorBeforeNot = null; michael@0: while (cursor <= tokIndex && (token = tokens[cursor++])) { michael@0: switch (_state) { michael@0: case CSS_STATES.property: michael@0: // From CSS_STATES.property, we can either go to CSS_STATES.value state michael@0: // when we hit the first ':' or CSS_STATES.selector if "}" is reached. michael@0: switch(token.tokenType) { michael@0: case ":": michael@0: scopeStack.push(":"); michael@0: if (tokens[cursor - 2].tokenType != "WHITESPACE") michael@0: propertyName = tokens[cursor - 2].value; michael@0: else michael@0: propertyName = tokens[cursor - 3].value; michael@0: _state = CSS_STATES.value; michael@0: break; michael@0: michael@0: case "}": michael@0: if (/[{f]/.test(peek(scopeStack))) { michael@0: let popped = scopeStack.pop(); michael@0: if (popped == "f") { michael@0: _state = CSS_STATES.frame; michael@0: } else { michael@0: selector = ""; michael@0: selectors = []; michael@0: _state = CSS_STATES.null; michael@0: } michael@0: } michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case CSS_STATES.value: michael@0: // From CSS_STATES.value, we can go to one of CSS_STATES.property, michael@0: // CSS_STATES.frame, CSS_STATES.selector and CSS_STATES.null michael@0: switch(token.tokenType) { michael@0: case ";": michael@0: if (/[:]/.test(peek(scopeStack))) { michael@0: scopeStack.pop(); michael@0: _state = CSS_STATES.property; michael@0: } michael@0: break; michael@0: michael@0: case "}": michael@0: if (peek(scopeStack) == ":") michael@0: scopeStack.pop(); michael@0: michael@0: if (/[{f]/.test(peek(scopeStack))) { michael@0: let popped = scopeStack.pop(); michael@0: if (popped == "f") { michael@0: _state = CSS_STATES.frame; michael@0: } else { michael@0: selector = ""; michael@0: selectors = []; michael@0: _state = CSS_STATES.null; michael@0: } michael@0: } michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case CSS_STATES.selector: michael@0: // From CSS_STATES.selector, we can only go to CSS_STATES.property when michael@0: // we hit "{" michael@0: if (token.tokenType == "{") { michael@0: scopeStack.push("{"); michael@0: _state = CSS_STATES.property; michael@0: selectors.push(selector); michael@0: selector = ""; michael@0: break; michael@0: } michael@0: switch(selectorState) { michael@0: case SELECTOR_STATES.id: michael@0: case SELECTOR_STATES.class: michael@0: case SELECTOR_STATES.tag: michael@0: switch(token.tokenType) { michael@0: case "HASH": michael@0: selectorState = SELECTOR_STATES.id; michael@0: selector += "#" + token.value; michael@0: break; michael@0: michael@0: case "DELIM": michael@0: if (token.value == ".") { michael@0: selectorState = SELECTOR_STATES.class; michael@0: selector += "."; michael@0: if (cursor <= tokIndex && michael@0: tokens[cursor].tokenType == "IDENT") { michael@0: token = tokens[cursor++]; michael@0: selector += token.value; michael@0: } michael@0: } else if (token.value == "#") { michael@0: selectorState = SELECTOR_STATES.id; michael@0: selector += "#"; michael@0: } else if (/[>~+]/.test(token.value)) { michael@0: selectorState = SELECTOR_STATES.null; michael@0: selector += token.value; michael@0: } else if (token.value == ",") { michael@0: selectorState = SELECTOR_STATES.null; michael@0: selectors.push(selector); michael@0: selector = ""; michael@0: } michael@0: break; michael@0: michael@0: case ":": michael@0: selectorState = SELECTOR_STATES.pseudo; michael@0: selector += ":"; michael@0: if (cursor > tokIndex) michael@0: break; michael@0: michael@0: token = tokens[cursor++]; michael@0: switch(token.tokenType) { michael@0: case "FUNCTION": michael@0: if (token.value == "not") { michael@0: selectorBeforeNot = selector; michael@0: selector = ""; michael@0: scopeStack.push("("); michael@0: } else { michael@0: selector += token.value + "("; michael@0: } michael@0: selectorState = SELECTOR_STATES.null; michael@0: break; michael@0: michael@0: case "IDENT": michael@0: selector += token.value; michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case "[": michael@0: selectorState = SELECTOR_STATES.attribute; michael@0: scopeStack.push("["); michael@0: selector += "["; michael@0: break; michael@0: michael@0: case ")": michael@0: if (peek(scopeStack) == "(") { michael@0: scopeStack.pop(); michael@0: selector = selectorBeforeNot + "not(" + selector + ")"; michael@0: selectorBeforeNot = null; michael@0: } else { michael@0: selector += ")"; michael@0: } michael@0: selectorState = SELECTOR_STATES.null; michael@0: break; michael@0: michael@0: case "WHITESPACE": michael@0: selectorState = SELECTOR_STATES.null; michael@0: selector && (selector += " "); michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case SELECTOR_STATES.null: michael@0: // From SELECTOR_STATES.null state, we can go to one of michael@0: // SELECTOR_STATES.id, SELECTOR_STATES.class or SELECTOR_STATES.tag michael@0: switch(token.tokenType) { michael@0: case "HASH": michael@0: selectorState = SELECTOR_STATES.id; michael@0: selector += "#" + token.value; michael@0: break; michael@0: michael@0: case "IDENT": michael@0: selectorState = SELECTOR_STATES.tag; michael@0: selector += token.value; michael@0: break; michael@0: michael@0: case "DELIM": michael@0: if (token.value == ".") { michael@0: selectorState = SELECTOR_STATES.class; michael@0: selector += "."; michael@0: if (cursor <= tokIndex && michael@0: tokens[cursor].tokenType == "IDENT") { michael@0: token = tokens[cursor++]; michael@0: selector += token.value; michael@0: } michael@0: } else if (token.value == "#") { michael@0: selectorState = SELECTOR_STATES.id; michael@0: selector += "#"; michael@0: } else if (token.value == "*") { michael@0: selectorState = SELECTOR_STATES.tag; michael@0: selector += "*"; michael@0: } else if (/[>~+]/.test(token.value)) { michael@0: selector += token.value; michael@0: } else if (token.value == ",") { michael@0: selectorState = SELECTOR_STATES.null; michael@0: selectors.push(selector); michael@0: selector = ""; michael@0: } michael@0: break; michael@0: michael@0: case ":": michael@0: selectorState = SELECTOR_STATES.pseudo; michael@0: selector += ":"; michael@0: if (cursor > tokIndex) michael@0: break; michael@0: michael@0: token = tokens[cursor++]; michael@0: switch(token.tokenType) { michael@0: case "FUNCTION": michael@0: if (token.value == "not") { michael@0: selectorBeforeNot = selector; michael@0: selector = ""; michael@0: scopeStack.push("("); michael@0: } else { michael@0: selector += token.value + "("; michael@0: } michael@0: selectorState = SELECTOR_STATES.null; michael@0: break; michael@0: michael@0: case "IDENT": michael@0: selector += token.value; michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case "[": michael@0: selectorState = SELECTOR_STATES.attribute; michael@0: scopeStack.push("["); michael@0: selector += "["; michael@0: break; michael@0: michael@0: case ")": michael@0: if (peek(scopeStack) == "(") { michael@0: scopeStack.pop(); michael@0: selector = selectorBeforeNot + "not(" + selector + ")"; michael@0: selectorBeforeNot = null; michael@0: } else { michael@0: selector += ")"; michael@0: } michael@0: selectorState = SELECTOR_STATES.null; michael@0: break; michael@0: michael@0: case "WHITESPACE": michael@0: selector && (selector += " "); michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case SELECTOR_STATES.pseudo: michael@0: switch(token.tokenType) { michael@0: case "DELIM": michael@0: if (/[>~+]/.test(token.value)) { michael@0: selectorState = SELECTOR_STATES.null; michael@0: selector += token.value; michael@0: } else if (token.value == ",") { michael@0: selectorState = SELECTOR_STATES.null; michael@0: selectors.push(selector); michael@0: selector = ""; michael@0: } michael@0: break; michael@0: michael@0: case ":": michael@0: selectorState = SELECTOR_STATES.pseudo; michael@0: selector += ":"; michael@0: if (cursor > tokIndex) michael@0: break; michael@0: michael@0: token = tokens[cursor++]; michael@0: switch(token.tokenType) { michael@0: case "FUNCTION": michael@0: if (token.value == "not") { michael@0: selectorBeforeNot = selector; michael@0: selector = ""; michael@0: scopeStack.push("("); michael@0: } else { michael@0: selector += token.value + "("; michael@0: } michael@0: selectorState = SELECTOR_STATES.null; michael@0: break; michael@0: michael@0: case "IDENT": michael@0: selector += token.value; michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case "[": michael@0: selectorState = SELECTOR_STATES.attribute; michael@0: scopeStack.push("["); michael@0: selector += "["; michael@0: break; michael@0: michael@0: case "WHITESPACE": michael@0: selectorState = SELECTOR_STATES.null; michael@0: selector && (selector += " "); michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case SELECTOR_STATES.attribute: michael@0: switch(token.tokenType) { michael@0: case "DELIM": michael@0: if (/[~|^$*]/.test(token.value)) { michael@0: selector += token.value; michael@0: token = tokens[cursor++]; michael@0: } michael@0: if(token.value == "=") { michael@0: selectorState = SELECTOR_STATES.value; michael@0: selector += token.value; michael@0: } michael@0: break; michael@0: michael@0: case "IDENT": michael@0: case "STRING": michael@0: selector += token.value; michael@0: break; michael@0: michael@0: case "]": michael@0: if (peek(scopeStack) == "[") michael@0: scopeStack.pop(); michael@0: michael@0: selectorState = SELECTOR_STATES.null; michael@0: selector += "]"; michael@0: break; michael@0: michael@0: case "WHITESPACE": michael@0: selector && (selector += " "); michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case SELECTOR_STATES.value: michael@0: switch(token.tokenType) { michael@0: case "STRING": michael@0: case "IDENT": michael@0: selector += token.value; michael@0: break; michael@0: michael@0: case "]": michael@0: if (peek(scopeStack) == "[") michael@0: scopeStack.pop(); michael@0: michael@0: selectorState = SELECTOR_STATES.null; michael@0: selector += "]"; michael@0: break; michael@0: michael@0: case "WHITESPACE": michael@0: selector && (selector += " "); michael@0: break; michael@0: } michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case CSS_STATES.null: michael@0: // From CSS_STATES.null state, we can go to either CSS_STATES.media or michael@0: // CSS_STATES.selector. michael@0: switch(token.tokenType) { michael@0: case "HASH": michael@0: selectorState = SELECTOR_STATES.id; michael@0: selector = "#" + token.value; michael@0: _state = CSS_STATES.selector; michael@0: break; michael@0: michael@0: case "IDENT": michael@0: selectorState = SELECTOR_STATES.tag; michael@0: selector = token.value; michael@0: _state = CSS_STATES.selector; michael@0: break; michael@0: michael@0: case "DELIM": michael@0: if (token.value == ".") { michael@0: selectorState = SELECTOR_STATES.class; michael@0: selector = "."; michael@0: _state = CSS_STATES.selector; michael@0: if (cursor <= tokIndex && michael@0: tokens[cursor].tokenType == "IDENT") { michael@0: token = tokens[cursor++]; michael@0: selector += token.value; michael@0: } michael@0: } else if (token.value == "#") { michael@0: selectorState = SELECTOR_STATES.id; michael@0: selector = "#"; michael@0: _state = CSS_STATES.selector; michael@0: } else if (token.value == "*") { michael@0: selectorState = SELECTOR_STATES.tag; michael@0: selector = "*"; michael@0: _state = CSS_STATES.selector; michael@0: } michael@0: break; michael@0: michael@0: case ":": michael@0: _state = CSS_STATES.selector; michael@0: selectorState = SELECTOR_STATES.pseudo; michael@0: selector += ":"; michael@0: if (cursor > tokIndex) michael@0: break; michael@0: michael@0: token = tokens[cursor++]; michael@0: switch(token.tokenType) { michael@0: case "FUNCTION": michael@0: if (token.value == "not") { michael@0: selectorBeforeNot = selector; michael@0: selector = ""; michael@0: scopeStack.push("("); michael@0: } else { michael@0: selector += token.value + "("; michael@0: } michael@0: selectorState = SELECTOR_STATES.null; michael@0: break; michael@0: michael@0: case "IDENT": michael@0: selector += token.value; michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case "[": michael@0: _state = CSS_STATES.selector; michael@0: selectorState = SELECTOR_STATES.attribute; michael@0: scopeStack.push("["); michael@0: selector += "["; michael@0: break; michael@0: michael@0: case "AT-KEYWORD": michael@0: _state = token.value.startsWith("m") ? CSS_STATES.media michael@0: : CSS_STATES.keyframes; michael@0: break; michael@0: michael@0: case "}": michael@0: if (peek(scopeStack) == "@m") michael@0: scopeStack.pop(); michael@0: michael@0: break; michael@0: } michael@0: break; michael@0: michael@0: case CSS_STATES.media: michael@0: // From CSS_STATES.media, we can only go to CSS_STATES.null state when michael@0: // we hit the first '{' michael@0: if (token.tokenType == "{") { michael@0: scopeStack.push("@m"); michael@0: _state = CSS_STATES.null; michael@0: } michael@0: break; michael@0: michael@0: case CSS_STATES.keyframes: michael@0: // From CSS_STATES.keyframes, we can only go to CSS_STATES.frame state michael@0: // when we hit the first '{' michael@0: if (token.tokenType == "{") { michael@0: scopeStack.push("@k"); michael@0: _state = CSS_STATES.frame; michael@0: } michael@0: break; michael@0: michael@0: case CSS_STATES.frame: michael@0: // From CSS_STATES.frame, we can either go to CSS_STATES.property state michael@0: // when we hit the first '{' or to CSS_STATES.selector when we hit '}' michael@0: if (token.tokenType == "{") { michael@0: scopeStack.push("f"); michael@0: _state = CSS_STATES.property; michael@0: } else if (token.tokenType == "}") { michael@0: if (peek(scopeStack) == "@k") michael@0: scopeStack.pop(); michael@0: michael@0: _state = CSS_STATES.null; michael@0: } michael@0: break; michael@0: } michael@0: if (_state == CSS_STATES.null) { michael@0: if (this.nullStates.length == 0) { michael@0: this.nullStates.push([token.loc.end.line, token.loc.end.column, michael@0: [...scopeStack]]); michael@0: continue; michael@0: } michael@0: let tokenLine = token.loc.end.line; michael@0: let tokenCh = token.loc.end.column; michael@0: if (tokenLine == 0) michael@0: continue; michael@0: if (matchedStateIndex > -1) michael@0: tokenLine += this.nullStates[matchedStateIndex][0]; michael@0: this.nullStates.push([tokenLine, tokenCh, [...scopeStack]]); michael@0: } michael@0: } michael@0: this.state = _state; michael@0: this.propertyName = _state == CSS_STATES.value ? propertyName : null; michael@0: this.selectorState = _state == CSS_STATES.selector ? selectorState : null; michael@0: this.selectorBeforeNot = selectorBeforeNot == null ? null: selectorBeforeNot; michael@0: if (token) { michael@0: selector = selector.slice(0, selector.length + token.loc.end.column - ch); michael@0: this.selector = selector; michael@0: } michael@0: else { michael@0: this.selector = ""; michael@0: } michael@0: this.selectors = selectors; michael@0: michael@0: if (token && token.tokenType != "WHITESPACE") { michael@0: this.completing = ((token.value || token.repr || token.tokenType) + "") michael@0: .slice(0, ch - token.loc.start.column) michael@0: .replace(/^[.#]$/, ""); michael@0: } else { michael@0: this.completing = ""; michael@0: } michael@0: // Special case the situation when the user just entered ":" after typing a michael@0: // property name. michael@0: if (this.completing == ":" && _state == CSS_STATES.value) michael@0: this.completing = ""; michael@0: michael@0: // Special check for !important; case. michael@0: if (token && tokens[cursor - 2] && tokens[cursor - 2].value == "!" && michael@0: this.completing == "important".slice(0, this.completing.length)) { michael@0: this.completing = "!" + this.completing; michael@0: } michael@0: return _state; michael@0: }, michael@0: michael@0: /** michael@0: * Queries the DOM Walker actor for suggestions regarding the selector being michael@0: * completed michael@0: */ michael@0: suggestSelectors: function () { michael@0: let walker = this.walker; michael@0: if (!walker) michael@0: return Promise.resolve([]); michael@0: michael@0: let query = this.selector; michael@0: // Even though the selector matched atleast one node, there is still michael@0: // possibility of suggestions. michael@0: switch(this.selectorState) { michael@0: case SELECTOR_STATES.null: michael@0: query += "*"; michael@0: break; michael@0: michael@0: case SELECTOR_STATES.tag: michael@0: query = query.slice(0, query.length - this.completing.length); michael@0: break; michael@0: michael@0: case SELECTOR_STATES.id: michael@0: case SELECTOR_STATES.class: michael@0: case SELECTOR_STATES.pseudo: michael@0: if (/^[.:#]$/.test(this.completing)) { michael@0: query = query.slice(0, query.length - this.completing.length); michael@0: this.completing = ""; michael@0: } else { michael@0: query = query.slice(0, query.length - this.completing.length - 1); michael@0: } michael@0: break; michael@0: } michael@0: michael@0: if (/[\s+>~]$/.test(query) && michael@0: this.selectorState != SELECTOR_STATES.attribute && michael@0: this.selectorState != SELECTOR_STATES.value) { michael@0: query += "*"; michael@0: } michael@0: michael@0: // Set the values that this request was supposed to suggest to. michael@0: this._currentQuery = query; michael@0: return walker.getSuggestionsForQuery(query, this.completing, this.selectorState) michael@0: .then(result => this.prepareSelectorResults(result)); michael@0: }, michael@0: michael@0: /** michael@0: * Prepares the selector suggestions returned by the walker actor. michael@0: */ michael@0: prepareSelectorResults: function(result) { michael@0: if (this._currentQuery != result.query) michael@0: return []; michael@0: michael@0: result = result.suggestions; michael@0: let query = this.selector; michael@0: let completion = []; michael@0: for (let value of result) { michael@0: switch(this.selectorState) { michael@0: case SELECTOR_STATES.id: michael@0: case SELECTOR_STATES.class: michael@0: case SELECTOR_STATES.pseudo: michael@0: if (/^[.:#]$/.test(this.completing)) { michael@0: value[0] = query.slice(0, query.length - this.completing.length) + michael@0: value[0]; michael@0: } else { michael@0: value[0] = query.slice(0, query.length - this.completing.length - 1) + michael@0: value[0]; michael@0: } michael@0: break; michael@0: michael@0: case SELECTOR_STATES.tag: michael@0: value[0] = query.slice(0, query.length - this.completing.length) + michael@0: value[0]; michael@0: break; michael@0: michael@0: case SELECTOR_STATES.null: michael@0: value[0] = query + value[0]; michael@0: break; michael@0: michael@0: default: michael@0: value[0] = query.slice(0, query.length - this.completing.length) + michael@0: value[0]; michael@0: } michael@0: completion.push({ michael@0: label: value[0], michael@0: preLabel: query, michael@0: text: value[0], michael@0: score: value[1] michael@0: }); michael@0: if (completion.length > this.maxEntries - 1) michael@0: break; michael@0: } michael@0: return completion; michael@0: }, michael@0: michael@0: /** michael@0: * Returns CSS property name suggestions based on the input. michael@0: * michael@0: * @param startProp {String} Initial part of the property being completed. michael@0: */ michael@0: completeProperties: function(startProp) { michael@0: let finalList = []; michael@0: if (!startProp) michael@0: return Promise.resolve(finalList); michael@0: michael@0: let length = propertyNames.length; michael@0: let i = 0, count = 0; michael@0: for (; i < length && count < this.maxEntries; i++) { michael@0: if (propertyNames[i].startsWith(startProp)) { michael@0: count++; michael@0: let propName = propertyNames[i]; michael@0: finalList.push({ michael@0: preLabel: startProp, michael@0: label: propName, michael@0: text: propName + ": " michael@0: }); michael@0: } else if (propertyNames[i] > startProp) { michael@0: // We have crossed all possible matches alphabetically. michael@0: break; michael@0: } michael@0: } michael@0: return Promise.resolve(finalList); michael@0: }, michael@0: michael@0: /** michael@0: * Returns CSS value suggestions based on the corresponding property. michael@0: * michael@0: * @param propName {String} The property to which the value being completed michael@0: * belongs. michael@0: * @param startValue {String} Initial part of the value being completed. michael@0: */ michael@0: completeValues: function(propName, startValue) { michael@0: let finalList = []; michael@0: let list = ["!important;", ...(properties[propName] || [])]; michael@0: // If there is no character being completed, we are showing an initial list michael@0: // of possible values. Skipping '!important' in this case. michael@0: if (!startValue) michael@0: list.splice(0, 1); michael@0: michael@0: let length = list.length; michael@0: let i = 0, count = 0; michael@0: for (; i < length && count < this.maxEntries; i++) { michael@0: if (list[i].startsWith(startValue)) { michael@0: count++; michael@0: let value = list[i]; michael@0: finalList.push({ michael@0: preLabel: startValue, michael@0: label: value, michael@0: text: value michael@0: }); michael@0: } else if (list[i] > startValue) { michael@0: // We have crossed all possible matches alphabetically. michael@0: break; michael@0: } michael@0: } michael@0: return Promise.resolve(finalList); michael@0: }, michael@0: michael@0: /** michael@0: * A biased binary search in a sorted array where the middle element is michael@0: * calculated based on the values at the lower and the upper index in each michael@0: * iteration. michael@0: * michael@0: * This method returns the index of the closest null state from the passed michael@0: * `line` argument. Once we have the closest null state, we can start applying michael@0: * the state machine logic from that location instead of the absolute starting michael@0: * of the CSS source. This speeds up the tokenizing and the state machine a michael@0: * lot while using autocompletion at high line numbers in a CSS source. michael@0: */ michael@0: findNearestNullState: function(line) { michael@0: let arr = this.nullStates; michael@0: let high = arr.length - 1; michael@0: let low = 0; michael@0: let target = 0; michael@0: michael@0: if (high < 0) michael@0: return -1; michael@0: if (arr[high][0] <= line) michael@0: return high; michael@0: if (arr[low][0] > line) michael@0: return -1; michael@0: michael@0: while (high > low) { michael@0: if (arr[low][0] <= line && arr[low [0]+ 1] > line) michael@0: return low; michael@0: if (arr[high][0] > line && arr[high - 1][0] <= line) michael@0: return high - 1; michael@0: michael@0: target = (((line - arr[low][0]) / (arr[high][0] - arr[low][0])) * michael@0: (high - low)) | 0; michael@0: michael@0: if (arr[target][0] <= line && arr[target + 1][0] > line) { michael@0: return target; michael@0: } else if (line > arr[target][0]) { michael@0: low = target + 1; michael@0: high--; michael@0: } else { michael@0: high = target - 1; michael@0: low++; michael@0: } michael@0: } michael@0: michael@0: return -1; michael@0: }, michael@0: michael@0: /** michael@0: * Invalidates the state cache for and above the line. michael@0: */ michael@0: invalidateCache: function(line) { michael@0: this.nullStates.length = this.findNearestNullState(line) + 1; michael@0: }, michael@0: michael@0: /** michael@0: * Get the state information about a token surrounding the {line, ch} position michael@0: * michael@0: * @param {string} source michael@0: * The complete source of the CSS file. Unlike resolve state method, michael@0: * this method requires the full source. michael@0: * @param {object} caret michael@0: * The line, ch position of the caret. michael@0: * michael@0: * @returns {object} michael@0: * An object containing the state of token covered by the caret. michael@0: * The object has following properties when the the state is michael@0: * "selector", "value" or "property", null otherwise: michael@0: * - state {string} one of CSS_STATES - "selector", "value" etc. michael@0: * - selector {string} The selector at the caret when `state` is michael@0: * selector. OR michael@0: * - selectors {[string]} Array of selector strings in case when michael@0: * `state` is "value" or "property" michael@0: * - propertyName {string} The property name at the current caret or michael@0: * the property name corresponding to the value at michael@0: * the caret. michael@0: * - value {string} The css value at the current caret. michael@0: * - loc {object} An object containing the starting and the ending michael@0: * caret position of the whole selector, value or property. michael@0: * - { start: {line, ch}, end: {line, ch}} michael@0: */ michael@0: getInfoAt: function(source, caret) { michael@0: // Limits the input source till the {line, ch} caret position michael@0: function limit(source, {line, ch}) { michael@0: line++; michael@0: let list = source.split("\n"); michael@0: if (list.length < line) michael@0: return source; michael@0: if (line == 1) michael@0: return list[0].slice(0, ch); michael@0: return [...list.slice(0, line - 1), list[line - 1].slice(0, ch)].join("\n"); michael@0: } michael@0: michael@0: // Get the state at the given line, ch michael@0: let state = this.resolveState(limit(source, caret), caret); michael@0: let propertyName = this.propertyName; michael@0: let {line, ch} = caret; michael@0: let sourceArray = source.split("\n"); michael@0: let limitedSource = limit(source, caret); michael@0: michael@0: /** michael@0: * Method to traverse forwards from the caret location to figure out the michael@0: * ending point of a selector or css value. michael@0: * michael@0: * @param {function} check michael@0: * A method which takes the current state as an input and determines michael@0: * whether the state changed or not. michael@0: */ michael@0: let traverseForward = check => { michael@0: let location; michael@0: // Backward loop to determine the beginning location of the selector. michael@0: do { michael@0: let lineText = sourceArray[line]; michael@0: if (line == caret.line) michael@0: lineText = lineText.substring(caret.ch); michael@0: michael@0: let tokens = cssTokenizer(lineText, {loc: true}); michael@0: let found = false; michael@0: let ech = line == caret.line ? caret.ch : 0; michael@0: for (let i = 0; i < tokens.length; i++) { michael@0: let token = tokens[i]; michael@0: // If the line is completely spaces, handle it differently michael@0: if (lineText.trim() == "") { michael@0: limitedSource += lineText; michael@0: } else { michael@0: limitedSource += sourceArray[line] michael@0: .substring(ech + token.loc.start.column, michael@0: ech + token.loc.end.column); michael@0: } michael@0: michael@0: // Whitespace cannot change state. michael@0: if (token.tokenType == "WHITESPACE") michael@0: continue; michael@0: michael@0: let state = this.resolveState(limitedSource, { michael@0: line: line, michael@0: ch: token.loc.end.column + ech michael@0: }); michael@0: if (check(state)) { michael@0: if (tokens[i - 1] && tokens[i - 1].tokenType == "WHITESPACE") michael@0: token = tokens[i - 1]; michael@0: location = { michael@0: line: line, michael@0: ch: token.loc.start.column + ech michael@0: }; michael@0: found = true; michael@0: break; michael@0: } michael@0: } michael@0: limitedSource += "\n"; michael@0: if (found) michael@0: break; michael@0: } while (line++ < sourceArray.length); michael@0: return location; michael@0: }; michael@0: michael@0: /** michael@0: * Method to traverse backwards from the caret location to figure out the michael@0: * starting point of a selector or css value. michael@0: * michael@0: * @param {function} check michael@0: * A method which takes the current state as an input and determines michael@0: * whether the state changed or not. michael@0: * @param {boolean} isValue michael@0: * true if the traversal is being done for a css value state. michael@0: */ michael@0: let traverseBackwards = (check, isValue) => { michael@0: let location; michael@0: // Backward loop to determine the beginning location of the selector. michael@0: do { michael@0: let lineText = sourceArray[line]; michael@0: if (line == caret.line) michael@0: lineText = lineText.substring(0, caret.ch); michael@0: michael@0: let tokens = cssTokenizer(lineText, {loc: true}); michael@0: let found = false; michael@0: let ech = 0; michael@0: for (let i = tokens.length - 2; i >= 0; i--) { michael@0: let token = tokens[i]; michael@0: // If the line is completely spaces, handle it differently michael@0: if (lineText.trim() == "") { michael@0: limitedSource = limitedSource.slice(0, -1 * lineText.length); michael@0: } else { michael@0: let length = token.loc.end.column - token.loc.start.column; michael@0: limitedSource = limitedSource.slice(0, -1 * length); michael@0: } michael@0: michael@0: // Whitespace cannot change state. michael@0: if (token.tokenType == "WHITESPACE") michael@0: continue; michael@0: michael@0: let state = this.resolveState(limitedSource, { michael@0: line: line, michael@0: ch: token.loc.start.column michael@0: }); michael@0: if (check(state)) { michael@0: if (tokens[i + 1] && tokens[i + 1].tokenType == "WHITESPACE") michael@0: token = tokens[i + 1]; michael@0: location = { michael@0: line: line, michael@0: ch: isValue ? token.loc.end.column: token.loc.start.column michael@0: }; michael@0: found = true; michael@0: break; michael@0: } michael@0: } michael@0: limitedSource = limitedSource.slice(0, -1); michael@0: if (found) michael@0: break; michael@0: } while (line-- >= 0); michael@0: return location; michael@0: }; michael@0: michael@0: if (state == CSS_STATES.selector) { michael@0: // For selector state, the ending and starting point of the selector is michael@0: // either when the state changes or the selector becomes empty and a michael@0: // single selector can span multiple lines. michael@0: // Backward loop to determine the beginning location of the selector. michael@0: let start = traverseBackwards(state => { michael@0: return (state != CSS_STATES.selector || michael@0: (this.selector == "" && this.selectorBeforeNot == null)); michael@0: }); michael@0: michael@0: line = caret.line; michael@0: limitedSource = limit(source, caret); michael@0: // Forward loop to determine the ending location of the selector. michael@0: let end = traverseForward(state => { michael@0: return (state != CSS_STATES.selector || michael@0: (this.selector == "" && this.selectorBeforeNot == null)); michael@0: }); michael@0: michael@0: // Since we have start and end positions, figure out the whole selector. michael@0: let selector = source.split("\n").slice(start.line, end.line + 1); michael@0: selector[selector.length - 1] = michael@0: selector[selector.length - 1].substring(0, end.ch); michael@0: selector[0] = selector[0].substring(start.ch); michael@0: selector = selector.join("\n"); michael@0: return { michael@0: state: state, michael@0: selector: selector, michael@0: loc: { michael@0: start: start, michael@0: end: end michael@0: } michael@0: }; michael@0: } michael@0: else if (state == CSS_STATES.property) { michael@0: // A property can only be a single word and thus very easy to calculate. michael@0: let tokens = cssTokenizer(sourceArray[line], {loc: true}); michael@0: for (let token of tokens) { michael@0: if (token.loc.start.column <= ch && token.loc.end.column >= ch) { michael@0: return { michael@0: state: state, michael@0: propertyName: token.value, michael@0: selectors: this.selectors, michael@0: loc: { michael@0: start: { michael@0: line: line, michael@0: ch: token.loc.start.column michael@0: }, michael@0: end: { michael@0: line: line, michael@0: ch: token.loc.end.column michael@0: } michael@0: } michael@0: }; michael@0: } michael@0: } michael@0: } michael@0: else if (state == CSS_STATES.value) { michael@0: // CSS value can be multiline too, so we go forward and backwards to michael@0: // determine the bounds of the value at caret michael@0: let start = traverseBackwards(state => state != CSS_STATES.value, true); michael@0: michael@0: line = caret.line; michael@0: limitedSource = limit(source, caret); michael@0: let end = traverseForward(state => state != CSS_STATES.value); michael@0: michael@0: let value = source.split("\n").slice(start.line, end.line + 1); michael@0: value[value.length - 1] = value[value.length - 1].substring(0, end.ch); michael@0: value[0] = value[0].substring(start.ch); michael@0: value = value.join("\n"); michael@0: return { michael@0: state: state, michael@0: propertyName: propertyName, michael@0: selectors: this.selectors, michael@0: value: value, michael@0: loc: { michael@0: start: start, michael@0: end: end michael@0: } michael@0: }; michael@0: } michael@0: return null; michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Returns a list of all property names and a map of property name vs possible michael@0: * CSS values provided by the Gecko engine. michael@0: * michael@0: * @return {Object} An object with following properties: michael@0: * - propertyNames {Array} Array of string containing all the possible michael@0: * CSS property names. michael@0: * - properties {Object|Map} A map where key is the property name and michael@0: * value is an array of string containing all the possible michael@0: * CSS values the property can have. michael@0: */ michael@0: function getCSSKeywords() { michael@0: let domUtils = Cc["@mozilla.org/inspector/dom-utils;1"] michael@0: .getService(Ci.inIDOMUtils); michael@0: let props = {}; michael@0: let propNames = domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES); michael@0: propNames.forEach(prop => { michael@0: props[prop] = domUtils.getCSSValuesForProperty(prop).sort(); michael@0: }); michael@0: return { michael@0: properties: props, michael@0: propertyNames: propNames.sort() michael@0: }; michael@0: } michael@0: michael@0: module.exports = CSSCompleter;