diff options
Diffstat (limited to 'devtools/shared/css/parsing-utils.js')
-rw-r--r-- | devtools/shared/css/parsing-utils.js | 1171 |
1 files changed, 1171 insertions, 0 deletions
diff --git a/devtools/shared/css/parsing-utils.js b/devtools/shared/css/parsing-utils.js new file mode 100644 index 000000000..f477b0f12 --- /dev/null +++ b/devtools/shared/css/parsing-utils.js @@ -0,0 +1,1171 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This file holds various CSS parsing and rewriting utilities. +// Some entry points of note are: +// parseDeclarations - parse a CSS rule into declarations +// RuleRewriter - rewrite CSS rule text +// parsePseudoClassesAndAttributes - parse selector and extract +// pseudo-classes +// parseSingleValue - parse a single CSS property value + +"use strict"; + +const {CSS_ANGLEUNIT} = require("devtools/shared/css/properties-db"); + +const promise = require("promise"); +const {getCSSLexer} = require("devtools/shared/css/lexer"); +const {Task} = require("devtools/shared/task"); + +const SELECTOR_ATTRIBUTE = exports.SELECTOR_ATTRIBUTE = 1; +const SELECTOR_ELEMENT = exports.SELECTOR_ELEMENT = 2; +const SELECTOR_PSEUDO_CLASS = exports.SELECTOR_PSEUDO_CLASS = 3; + +// Used to test whether a newline appears anywhere in some text. +const NEWLINE_RX = /[\r\n]/; +// Used to test whether a bit of text starts an empty comment, either +// an "ordinary" /* ... */ comment, or a "heuristic bypass" comment +// like /*! ... */. +const EMPTY_COMMENT_START_RX = /^\/\*!?[ \r\n\t\f]*$/; +// Used to test whether a bit of text ends an empty comment. +const EMPTY_COMMENT_END_RX = /^[ \r\n\t\f]*\*\//; +// Used to test whether a string starts with a blank line. +const BLANK_LINE_RX = /^[ \t]*(?:\r\n|\n|\r|\f|$)/; + +// When commenting out a declaration, we put this character into the +// comment opener so that future parses of the commented text know to +// bypass the property name validity heuristic. +const COMMENT_PARSING_HEURISTIC_BYPASS_CHAR = "!"; + +/** + * A generator function that lexes a CSS source string, yielding the + * CSS tokens. Comment tokens are dropped. + * + * @param {String} CSS source string + * @yield {CSSToken} The next CSSToken that is lexed + * @see CSSToken for details about the returned tokens + */ +function* cssTokenizer(string) { + let lexer = getCSSLexer(string); + while (true) { + let token = lexer.nextToken(); + if (!token) { + break; + } + // None of the existing consumers want comments. + if (token.tokenType !== "comment") { + yield token; + } + } +} + +/** + * Pass |string| to the CSS lexer and return an array of all the + * returned tokens. Comment tokens are not included. In addition to + * the usual information, each token will have starting and ending + * line and column information attached. Specifically, each token + * has an additional "loc" attribute. This attribute is an object + * of the form {line: L, column: C}. Lines and columns are both zero + * based. + * + * It's best not to add new uses of this function. In general it is + * simpler and better to use the CSSToken offsets, rather than line + * and column. Also, this function lexes the entire input string at + * once, rather than lazily yielding a token stream. Use + * |cssTokenizer| or |getCSSLexer| instead. + * + * @param{String} string The input string. + * @return {Array} An array of tokens (@see CSSToken) that have + * line and column information. + */ +function cssTokenizerWithLineColumn(string) { + let lexer = getCSSLexer(string); + let result = []; + let prevToken = undefined; + while (true) { + let token = lexer.nextToken(); + let lineNumber = lexer.lineNumber; + let columnNumber = lexer.columnNumber; + + if (prevToken) { + prevToken.loc.end = { + line: lineNumber, + column: columnNumber + }; + } + + if (!token) { + break; + } + + if (token.tokenType === "comment") { + // We've already dealt with the previous token's location. + prevToken = undefined; + } else { + let startLoc = { + line: lineNumber, + column: columnNumber + }; + token.loc = {start: startLoc}; + + result.push(token); + prevToken = token; + } + } + + return result; +} + +/** + * Escape a comment body. Find the comment start and end strings in a + * string and inserts backslashes so that the resulting text can + * itself be put inside a comment. + * + * @param {String} inputString + * input string + * @return {String} the escaped result + */ +function escapeCSSComment(inputString) { + let result = inputString.replace(/\/(\\*)\*/g, "/\\$1*"); + return result.replace(/\*(\\*)\//g, "*\\$1/"); +} + +/** + * Un-escape a comment body. This undoes any comment escaping that + * was done by escapeCSSComment. That is, given input like "/\* + * comment *\/", it will strip the backslashes. + * + * @param {String} inputString + * input string + * @return {String} the un-escaped result + */ +function unescapeCSSComment(inputString) { + let result = inputString.replace(/\/\\(\\*)\*/g, "/$1*"); + return result.replace(/\*\\(\\*)\//g, "*$1/"); +} + +/** + * A helper function for @see parseDeclarations that handles parsing + * of comment text. This wraps a recursive call to parseDeclarations + * with the processing needed to ensure that offsets in the result + * refer back to the original, unescaped, input string. + * + * @param {Function} isCssPropertyKnown + * A function to check if the CSS property is known. This is either an + * internal server function or from the CssPropertiesFront. + * @param {String} commentText The text of the comment, without the + * delimiters. + * @param {Number} startOffset The offset of the comment opener + * in the original text. + * @param {Number} endOffset The offset of the comment closer + * in the original text. + * @return {array} Array of declarations of the same form as returned + * by parseDeclarations. + */ +function parseCommentDeclarations(isCssPropertyKnown, commentText, startOffset, + endOffset) { + let commentOverride = false; + if (commentText === "") { + return []; + } else if (commentText[0] === COMMENT_PARSING_HEURISTIC_BYPASS_CHAR) { + // This is the special sign that the comment was written by + // rewriteDeclarations and so we should bypass the usual + // heuristic. + commentOverride = true; + commentText = commentText.substring(1); + } + + let rewrittenText = unescapeCSSComment(commentText); + + // We might have rewritten an embedded comment. For example + // /\* ... *\/ would turn into /* ... */. + // This rewriting is necessary for proper lexing, but it means + // that the offsets we get back can be off. So now we compute + // a map so that we can rewrite offsets later. The map is the same + // length as |rewrittenText| and tells us how to map an index + // into |rewrittenText| to an index into |commentText|. + // + // First, we find the location of each comment starter or closer in + // |rewrittenText|. At these spots we put a 1 into |rewrites|. + // Then we walk the array again, using the elements to compute a + // delta, which we use to make the final mapping. + // + // Note we allocate one extra entry because we can see an ending + // offset that is equal to the length. + let rewrites = new Array(rewrittenText.length + 1).fill(0); + + let commentRe = /\/\\*\*|\*\\*\//g; + while (true) { + let matchData = commentRe.exec(rewrittenText); + if (!matchData) { + break; + } + rewrites[matchData.index] = 1; + } + + let delta = 0; + for (let i = 0; i <= rewrittenText.length; ++i) { + delta += rewrites[i]; + // |startOffset| to add the offset from the comment starter, |+2| + // for the length of the "/*", then |i| and |delta| as described + // above. + rewrites[i] = startOffset + 2 + i + delta; + if (commentOverride) { + ++rewrites[i]; + } + } + + // Note that we pass "false" for parseComments here. It doesn't + // seem worthwhile to support declarations in comments-in-comments + // here, as there's no way to generate those using the tools, and + // users would be crazy to write such things. + let newDecls = parseDeclarationsInternal(isCssPropertyKnown, rewrittenText, + false, true, commentOverride); + for (let decl of newDecls) { + decl.offsets[0] = rewrites[decl.offsets[0]]; + decl.offsets[1] = rewrites[decl.offsets[1]]; + decl.colonOffsets[0] = rewrites[decl.colonOffsets[0]]; + decl.colonOffsets[1] = rewrites[decl.colonOffsets[1]]; + decl.commentOffsets = [startOffset, endOffset]; + } + return newDecls; +} + +/** + * A helper function for parseDeclarationsInternal that creates a new + * empty declaration. + * + * @return {object} an empty declaration of the form returned by + * parseDeclarations + */ +function getEmptyDeclaration() { + return {name: "", value: "", priority: "", + terminator: "", + offsets: [undefined, undefined], + colonOffsets: false}; +} + +/** + * A helper function that does all the parsing work for + * parseDeclarations. This is separate because it has some arguments + * that don't make sense in isolation. + * + * The return value and arguments are like parseDeclarations, with + * these additional arguments. + * + * @param {Function} isCssPropertyKnown + * Function to check if the CSS property is known. + * @param {Boolean} inComment + * If true, assume that this call is parsing some text + * which came from a comment in another declaration. + * In this case some heuristics are used to avoid parsing + * text which isn't obviously a series of declarations. + * @param {Boolean} commentOverride + * This only makes sense when inComment=true. + * When true, assume that the comment was generated by + * rewriteDeclarations, and skip the usual name-checking + * heuristic. + */ +function parseDeclarationsInternal(isCssPropertyKnown, inputString, + parseComments, inComment, commentOverride) { + if (inputString === null || inputString === undefined) { + throw new Error("empty input string"); + } + + let lexer = getCSSLexer(inputString); + + let declarations = [getEmptyDeclaration()]; + let lastProp = declarations[0]; + + let current = "", hasBang = false; + while (true) { + let token = lexer.nextToken(); + if (!token) { + break; + } + + // Ignore HTML comment tokens (but parse anything they might + // happen to surround). + if (token.tokenType === "htmlcomment") { + continue; + } + + // Update the start and end offsets of the declaration, but only + // when we see a significant token. + if (token.tokenType !== "whitespace" && token.tokenType !== "comment") { + if (lastProp.offsets[0] === undefined) { + lastProp.offsets[0] = token.startOffset; + } + lastProp.offsets[1] = token.endOffset; + } else if (lastProp.name && !current && !hasBang && + !lastProp.priority && lastProp.colonOffsets[1]) { + // Whitespace appearing after the ":" is attributed to it. + lastProp.colonOffsets[1] = token.endOffset; + } + + if (token.tokenType === "symbol" && token.text === ":") { + if (!lastProp.name) { + // Set the current declaration name if there's no name yet + lastProp.name = current.trim(); + lastProp.colonOffsets = [token.startOffset, token.endOffset]; + current = ""; + hasBang = false; + + // When parsing a comment body, if the left-hand-side is not a + // valid property name, then drop it and stop parsing. + if (inComment && !commentOverride && + !isCssPropertyKnown(lastProp.name)) { + lastProp.name = null; + break; + } + } else { + // Otherwise, just append ':' to the current value (declaration value + // with colons) + current += ":"; + } + } else if (token.tokenType === "symbol" && token.text === ";") { + lastProp.terminator = ""; + // When parsing a comment, if the name hasn't been set, then we + // have probably just seen an ordinary semicolon used in text, + // so drop this and stop parsing. + if (inComment && !lastProp.name) { + current = ""; + break; + } + lastProp.value = current.trim(); + current = ""; + hasBang = false; + declarations.push(getEmptyDeclaration()); + lastProp = declarations[declarations.length - 1]; + } else if (token.tokenType === "ident") { + if (token.text === "important" && hasBang) { + lastProp.priority = "important"; + hasBang = false; + } else { + if (hasBang) { + current += "!"; + } + // Re-escape the token to avoid dequoting problems. + // See bug 1287620. + current += CSS.escape(token.text); + } + } else if (token.tokenType === "symbol" && token.text === "!") { + hasBang = true; + } else if (token.tokenType === "whitespace") { + if (current !== "") { + current += " "; + } + } else if (token.tokenType === "comment") { + if (parseComments && !lastProp.name && !lastProp.value) { + let commentText = inputString.substring(token.startOffset + 2, + token.endOffset - 2); + let newDecls = parseCommentDeclarations(isCssPropertyKnown, commentText, + token.startOffset, + token.endOffset); + + // Insert the new declarations just before the final element. + let lastDecl = declarations.pop(); + declarations = [...declarations, ...newDecls, lastDecl]; + } else { + current += " "; + } + } else { + current += inputString.substring(token.startOffset, token.endOffset); + } + } + + // Handle whatever trailing properties or values might still be there + if (current) { + if (!lastProp.name) { + // Ignore this case in comments. + if (!inComment) { + // Trailing property found, e.g. p1:v1;p2:v2;p3 + lastProp.name = current.trim(); + } + } else { + // Trailing value found, i.e. value without an ending ; + lastProp.value = current.trim(); + let terminator = lexer.performEOFFixup("", true); + lastProp.terminator = terminator + ";"; + // If the input was unterminated, attribute the remainder to + // this property. This avoids some bad behavior when rewriting + // an unterminated comment. + if (terminator) { + lastProp.offsets[1] = inputString.length; + } + } + } + + // Remove declarations that have neither a name nor a value + declarations = declarations.filter(prop => prop.name || prop.value); + + return declarations; +} + +/** + * Returns an array of CSS declarations given a string. + * For example, parseDeclarations(isCssPropertyKnown, "width: 1px; height: 1px") + * would return: + * [{name:"width", value: "1px"}, {name: "height", "value": "1px"}] + * + * The input string is assumed to only contain declarations so { and } + * characters will be treated as part of either the property or value, + * depending where it's found. + * + * @param {Function} isCssPropertyKnown + * A function to check if the CSS property is known. This is either an + * internal server function or from the CssPropertiesFront. + * that are supported by the server. + * @param {String} inputString + * An input string of CSS + * @param {Boolean} parseComments + * If true, try to parse the contents of comments as well. + * A comment will only be parsed if it occurs outside of + * the body of some other declaration. + * @return {Array} an array of objects with the following signature: + * [{"name": string, "value": string, "priority": string, + * "terminator": string, + * "offsets": [start, end], "colonOffsets": [start, end]}, + * ...] + * Here, "offsets" holds the offsets of the start and end + * of the declaration text, in a form suitable for use with + * String.substring. + * "terminator" is a string to use to terminate the declaration, + * usually "" to mean no additional termination is needed. + * "colonOffsets" holds the start and end locations of the + * ":" that separates the property name from the value. + * If the declaration appears in a comment, then there will + * be an additional {"commentOffsets": [start, end] property + * on the object, which will hold the offsets of the start + * and end of the enclosing comment. + */ +function parseDeclarations(isCssPropertyKnown, inputString, + parseComments = false) { + return parseDeclarationsInternal(isCssPropertyKnown, inputString, + parseComments, false, false); +} + +/** + * Return an object that can be used to rewrite declarations in some + * source text. The source text and parsing are handled in the same + * way as @see parseDeclarations, with |parseComments| being true. + * Rewriting is done by calling one of the modification functions like + * setPropertyEnabled. The returned object has the same interface + * as @see RuleModificationList. + * + * An example showing how to disable the 3rd property in a rule: + * + * let rewriter = new RuleRewriter(isCssPropertyKnown, ruleActor, + * ruleActor.authoredText); + * rewriter.setPropertyEnabled(3, "color", false); + * rewriter.apply().then(() => { ... the change is made ... }); + * + * The exported rewriting methods are |renameProperty|, |setPropertyEnabled|, + * |createProperty|, |setProperty|, and |removeProperty|. The |apply| + * method can be used to send the edited text to the StyleRuleActor; + * |getDefaultIndentation| is useful for the methods requiring a + * default indentation value; and |getResult| is useful for testing. + * + * Additionally, editing will set the |changedDeclarations| property + * on this object. This property has the same form as the |changed| + * property of the object returned by |getResult|. + * + * @param {Function} isCssPropertyKnown + * A function to check if the CSS property is known. This is either an + * internal server function or from the CssPropertiesFront. + * that are supported by the server. Note that if Bug 1222047 + * is completed then isCssPropertyKnown will not need to be passed in. + * The CssProperty front will be able to obtained directly from the + * RuleRewriter. + * @param {StyleRuleFront} rule The style rule to use. Note that this + * is only needed by the |apply| and |getDefaultIndentation| methods; + * and in particular for testing it can be |null|. + * @param {String} inputString The CSS source text to parse and modify. + * @return {Object} an object that can be used to rewrite the input text. + */ +function RuleRewriter(isCssPropertyKnown, rule, inputString) { + this.rule = rule; + this.isCssPropertyKnown = isCssPropertyKnown; + + // Keep track of which any declarations we had to rewrite while + // performing the requested action. + this.changedDeclarations = {}; + + // If not null, a promise that must be wait upon before |apply| can + // do its work. + this.editPromise = null; + + // If the |defaultIndentation| property is set, then it is used; + // otherwise the RuleRewriter will try to compute the default + // indentation based on the style sheet's text. This override + // facility is for testing. + this.defaultIndentation = null; + + this.startInitialization(inputString); +} + +RuleRewriter.prototype = { + /** + * An internal function to initialize the rewriter with a given + * input string. + * + * @param {String} inputString the input to use + */ + startInitialization: function (inputString) { + this.inputString = inputString; + // Whether there are any newlines in the input text. + this.hasNewLine = /[\r\n]/.test(this.inputString); + // The declarations. + this.declarations = parseDeclarations(this.isCssPropertyKnown, this.inputString, + true); + this.decl = null; + this.result = null; + }, + + /** + * An internal function to complete initialization and set some + * properties for further processing. + * + * @param {Number} index The index of the property to modify + */ + completeInitialization: function (index) { + if (index < 0) { + throw new Error("Invalid index " + index + ". Expected positive integer"); + } + // |decl| is the declaration to be rewritten, or null if there is no + // declaration corresponding to |index|. + // |result| is used to accumulate the result text. + if (index < this.declarations.length) { + this.decl = this.declarations[index]; + this.result = this.inputString.substring(0, this.decl.offsets[0]); + } else { + this.decl = null; + this.result = this.inputString; + } + }, + + /** + * A helper function to compute the indentation of some text. This + * examines the rule's existing text to guess the indentation to use; + * unlike |getDefaultIndentation|, which examines the entire style + * sheet. + * + * @param {String} string the input text + * @param {Number} offset the offset at which to compute the indentation + * @return {String} the indentation at the indicated position + */ + getIndentation: function (string, offset) { + let originalOffset = offset; + for (--offset; offset >= 0; --offset) { + let c = string[offset]; + if (c === "\r" || c === "\n" || c === "\f") { + return string.substring(offset + 1, originalOffset); + } + if (c !== " " && c !== "\t") { + // Found some non-whitespace character before we found a newline + // -- let's reset the starting point and keep going, as we saw + // something on the line before the declaration. + originalOffset = offset; + } + } + // Ran off the end. + return ""; + }, + + /** + * Modify a property value to ensure it is "lexically safe" for + * insertion into a style sheet. This function doesn't attempt to + * ensure that the resulting text is a valid value for the given + * property; but rather just that inserting the text into the style + * sheet will not cause unwanted changes to other rules or + * declarations. + * + * @param {String} text The input text. This should include the trailing ";". + * @return {Array} An array of the form [anySanitized, text], where + * |anySanitized| is a boolean that indicates + * whether anything substantive has changed; and + * where |text| is the text that has been rewritten + * to be "lexically safe". + */ + sanitizePropertyValue: function (text) { + let lexer = getCSSLexer(text); + + let result = ""; + let previousOffset = 0; + let braceDepth = 0; + let anySanitized = false; + while (true) { + let token = lexer.nextToken(); + if (!token) { + break; + } + + if (token.tokenType === "symbol") { + switch (token.text) { + case ";": + // We simply drop the ";" here. This lets us cope with + // declarations that don't have a ";" and also other + // termination. The caller handles adding the ";" again. + result += text.substring(previousOffset, token.startOffset); + previousOffset = token.endOffset; + break; + + case "{": + ++braceDepth; + break; + + case "}": + --braceDepth; + if (braceDepth < 0) { + // Found an unmatched close bracket. + braceDepth = 0; + // Copy out text from |previousOffset|. + result += text.substring(previousOffset, token.startOffset); + // Quote the offending symbol. + result += "\\" + token.text; + previousOffset = token.endOffset; + anySanitized = true; + } + break; + } + } + } + + // Copy out any remaining text, then any needed terminators. + result += text.substring(previousOffset, text.length); + let eofFixup = lexer.performEOFFixup("", true); + if (eofFixup) { + anySanitized = true; + result += eofFixup; + } + return [anySanitized, result]; + }, + + /** + * Start at |index| and skip whitespace + * backward in |string|. Return the index of the first + * non-whitespace character, or -1 if the entire string was + * whitespace. + * @param {String} string the input string + * @param {Number} index the index at which to start + * @return {Number} index of the first non-whitespace character, or -1 + */ + skipWhitespaceBackward: function (string, index) { + for (--index; + index >= 0 && (string[index] === " " || string[index] === "\t"); + --index) { + // Nothing. + } + return index; + }, + + /** + * Terminate a given declaration, if needed. + * + * @param {Number} index The index of the rule to possibly + * terminate. It might be invalid, so this + * function must check for that. + */ + maybeTerminateDecl: function (index) { + if (index < 0 || index >= this.declarations.length + // No need to rewrite declarations in comments. + || ("commentOffsets" in this.declarations[index])) { + return; + } + + let termDecl = this.declarations[index]; + let endIndex = termDecl.offsets[1]; + // Due to an oddity of the lexer, we might have gotten a bit of + // extra whitespace in a trailing bad_url token -- so be sure to + // skip that as well. + endIndex = this.skipWhitespaceBackward(this.result, endIndex) + 1; + + let trailingText = this.result.substring(endIndex); + if (termDecl.terminator) { + // Insert the terminator just at the end of the declaration, + // before any trailing whitespace. + this.result = this.result.substring(0, endIndex) + termDecl.terminator + + trailingText; + // In a couple of cases, we may have had to add something to + // terminate the declaration, but the termination did not + // actually affect the property's value -- and at this spot, we + // only care about reporting value changes. In particular, we + // might have added a plain ";", or we might have terminated a + // comment with "*/;". Neither of these affect the value. + if (termDecl.terminator !== ";" && termDecl.terminator !== "*/;") { + this.changedDeclarations[index] = + termDecl.value + termDecl.terminator.slice(0, -1); + } + } + // If the rule generally has newlines, but this particular + // declaration doesn't have a trailing newline, insert one now. + // Maybe this style is too weird to bother with. + if (this.hasNewLine && !NEWLINE_RX.test(trailingText)) { + this.result += "\n"; + } + }, + + /** + * Sanitize the given property value and return the sanitized form. + * If the property is rewritten during sanitization, make a note in + * |changedDeclarations|. + * + * @param {String} text The property text. + * @param {Number} index The index of the property. + * @return {String} The sanitized text. + */ + sanitizeText: function (text, index) { + let [anySanitized, sanitizedText] = this.sanitizePropertyValue(text); + if (anySanitized) { + this.changedDeclarations[index] = sanitizedText; + } + return sanitizedText; + }, + + /** + * Rename a declaration. + * + * @param {Number} index index of the property in the rule. + * @param {String} name current name of the property + * @param {String} newName new name of the property + */ + renameProperty: function (index, name, newName) { + this.completeInitialization(index); + this.result += CSS.escape(newName); + // We could conceivably compute the name offsets instead so we + // could preserve white space and comments on the LHS of the ":". + this.completeCopying(this.decl.colonOffsets[0]); + }, + + /** + * Enable or disable a declaration + * + * @param {Number} index index of the property in the rule. + * @param {String} name current name of the property + * @param {Boolean} isEnabled true if the property should be enabled; + * false if it should be disabled + */ + setPropertyEnabled: function (index, name, isEnabled) { + this.completeInitialization(index); + const decl = this.decl; + let copyOffset = decl.offsets[1]; + if (isEnabled) { + // Enable it. First see if the comment start can be deleted. + let commentStart = decl.commentOffsets[0]; + if (EMPTY_COMMENT_START_RX.test(this.result.substring(commentStart))) { + this.result = this.result.substring(0, commentStart); + } else { + this.result += "*/ "; + } + + // Insert the name and value separately, so we can report + // sanitization changes properly. + let commentNamePart = + this.inputString.substring(decl.offsets[0], + decl.colonOffsets[1]); + this.result += unescapeCSSComment(commentNamePart); + + // When uncommenting, we must be sure to sanitize the text, to + // avoid things like /* decl: }; */, which will be accepted as + // a property but which would break the entire style sheet. + let newText = this.inputString.substring(decl.colonOffsets[1], + decl.offsets[1]); + newText = unescapeCSSComment(newText).trimRight(); + this.result += this.sanitizeText(newText, index) + ";"; + + // See if the comment end can be deleted. + let trailingText = this.inputString.substring(decl.offsets[1]); + if (EMPTY_COMMENT_END_RX.test(trailingText)) { + copyOffset = decl.commentOffsets[1]; + } else { + this.result += " /*"; + } + } else { + // Disable it. Note that we use our special comment syntax + // here. + let declText = this.inputString.substring(decl.offsets[0], + decl.offsets[1]); + this.result += "/*" + COMMENT_PARSING_HEURISTIC_BYPASS_CHAR + + " " + escapeCSSComment(declText) + " */"; + } + this.completeCopying(copyOffset); + }, + + /** + * Return a promise that will be resolved to the default indentation + * of the rule. This is a helper for internalCreateProperty. + * + * @return {Promise} a promise that will be resolved to a string + * that holds the default indentation that should be used + * for edits to the rule. + */ + getDefaultIndentation: function () { + return this.rule.parentStyleSheet.guessIndentation(); + }, + + /** + * An internal function to create a new declaration. This does all + * the work of |createProperty|. + * + * @param {Number} index index of the property in the rule. + * @param {String} name name of the new property + * @param {String} value value of the new property + * @param {String} priority priority of the new property; either + * the empty string or "important" + * @param {Boolean} enabled True if the new property should be + * enabled, false if disabled + * @return {Promise} a promise that is resolved when the edit has + * completed + */ + internalCreateProperty: Task.async(function* (index, name, value, priority, enabled) { + this.completeInitialization(index); + let newIndentation = ""; + if (this.hasNewLine) { + if (this.declarations.length > 0) { + newIndentation = this.getIndentation(this.inputString, + this.declarations[0].offsets[0]); + } else if (this.defaultIndentation) { + newIndentation = this.defaultIndentation; + } else { + newIndentation = yield this.getDefaultIndentation(); + } + } + + this.maybeTerminateDecl(index - 1); + + // If we generally have newlines, and if skipping whitespace + // backward stops at a newline, then insert our text before that + // whitespace. This ensures the indentation we computed is what + // is actually used. + let savedWhitespace = ""; + if (this.hasNewLine) { + let wsOffset = this.skipWhitespaceBackward(this.result, + this.result.length); + if (this.result[wsOffset] === "\r" || this.result[wsOffset] === "\n") { + savedWhitespace = this.result.substring(wsOffset + 1); + this.result = this.result.substring(0, wsOffset + 1); + } + } + + let newText = CSS.escape(name) + ": " + this.sanitizeText(value, index); + if (priority === "important") { + newText += " !important"; + } + newText += ";"; + + if (!enabled) { + newText = "/*" + COMMENT_PARSING_HEURISTIC_BYPASS_CHAR + " " + + escapeCSSComment(newText) + " */"; + } + + this.result += newIndentation + newText; + if (this.hasNewLine) { + this.result += "\n"; + } + this.result += savedWhitespace; + + if (this.decl) { + // Still want to copy in the declaration previously at this + // index. + this.completeCopying(this.decl.offsets[0]); + } + }), + + /** + * Create a new declaration. + * + * @param {Number} index index of the property in the rule. + * @param {String} name name of the new property + * @param {String} value value of the new property + * @param {String} priority priority of the new property; either + * the empty string or "important" + * @param {Boolean} enabled True if the new property should be + * enabled, false if disabled + */ + createProperty: function (index, name, value, priority, enabled) { + this.editPromise = this.internalCreateProperty(index, name, value, + priority, enabled); + }, + + /** + * Set a declaration's value. + * + * @param {Number} index index of the property in the rule. + * This can be -1 in the case where + * the rule does not support setRuleText; + * generally for setting properties + * on an element's style. + * @param {String} name the property's name + * @param {String} value the property's value + * @param {String} priority the property's priority, either the empty + * string or "important" + */ + setProperty: function (index, name, value, priority) { + this.completeInitialization(index); + // We might see a "set" on a previously non-existent property; in + // that case, act like "create". + if (!this.decl) { + this.createProperty(index, name, value, priority, true); + return; + } + + // Note that this assumes that "set" never operates on disabled + // properties. + this.result += this.inputString.substring(this.decl.offsets[0], + this.decl.colonOffsets[1]) + + this.sanitizeText(value, index); + + if (priority === "important") { + this.result += " !important"; + } + this.result += ";"; + this.completeCopying(this.decl.offsets[1]); + }, + + /** + * Remove a declaration. + * + * @param {Number} index index of the property in the rule. + * @param {String} name the name of the property to remove + */ + removeProperty: function (index, name) { + this.completeInitialization(index); + + // If asked to remove a property that does not exist, bail out. + if (!this.decl) { + return; + } + + // If the property is disabled, then first enable it, and then + // delete it. We take this approach because we want to remove the + // entire comment if possible; but the logic for dealing with + // comments is hairy and already implemented in + // setPropertyEnabled. + if (this.decl.commentOffsets) { + this.setPropertyEnabled(index, name, true); + this.startInitialization(this.result); + this.completeInitialization(index); + } + + let copyOffset = this.decl.offsets[1]; + // Maybe removing this rule left us with a completely blank + // line. In this case, we'll delete the whole thing. We only + // bother with this if we're looking at sources that already + // have a newline somewhere. + if (this.hasNewLine) { + let nlOffset = this.skipWhitespaceBackward(this.result, + this.decl.offsets[0]); + if (nlOffset < 0 || this.result[nlOffset] === "\r" || + this.result[nlOffset] === "\n") { + let trailingText = this.inputString.substring(copyOffset); + let match = BLANK_LINE_RX.exec(trailingText); + if (match) { + this.result = this.result.substring(0, nlOffset + 1); + copyOffset += match[0].length; + } + } + } + this.completeCopying(copyOffset); + }, + + /** + * An internal function to copy any trailing text to the output + * string. + * + * @param {Number} copyOffset Offset into |inputString| of the + * final text to copy to the output string. + */ + completeCopying: function (copyOffset) { + // Add the trailing text. + this.result += this.inputString.substring(copyOffset); + }, + + /** + * Apply the modifications in this object to the associated rule. + * + * @return {Promise} A promise which will be resolved when the modifications + * are complete. + */ + apply: function () { + return promise.resolve(this.editPromise).then(() => { + return this.rule.setRuleText(this.result); + }); + }, + + /** + * Get the result of the rewriting. This is used for testing. + * + * @return {object} an object of the form {changed: object, text: string} + * |changed| is an object where each key is + * the index of a property whose value had to be + * rewritten during the sanitization process, and + * whose value is the new text of the property. + * |text| is the rewritten text of the rule. + */ + getResult: function () { + return {changed: this.changedDeclarations, text: this.result}; + }, +}; + +/** + * Returns an array of the parsed CSS selector value and type given a string. + * + * The components making up the CSS selector can be extracted into 3 different + * types: element, attribute and pseudoclass. The object that is appended to + * the returned array contains the value related to one of the 3 types described + * along with the actual type. + * + * The following are the 3 types that can be returned in the object signature: + * (1) SELECTOR_ATTRIBUTE + * (2) SELECTOR_ELEMENT + * (3) SELECTOR_PSEUDO_CLASS + * + * @param {String} value + * The CSS selector text. + * @return {Array} an array of objects with the following signature: + * [{ "value": string, "type": integer }, ...] + */ +function parsePseudoClassesAndAttributes(value) { + if (!value) { + throw new Error("empty input string"); + } + + let tokens = cssTokenizer(value); + let result = []; + let current = ""; + let functionCount = 0; + let hasAttribute = false; + let hasColon = false; + + for (let token of tokens) { + if (token.tokenType === "ident") { + current += value.substring(token.startOffset, token.endOffset); + + if (hasColon && !functionCount) { + if (current) { + result.push({ value: current, type: SELECTOR_PSEUDO_CLASS }); + } + + current = ""; + hasColon = false; + } + } else if (token.tokenType === "symbol" && token.text === ":") { + if (!hasColon) { + if (current) { + result.push({ value: current, type: SELECTOR_ELEMENT }); + } + + current = ""; + hasColon = true; + } + + current += token.text; + } else if (token.tokenType === "function") { + current += value.substring(token.startOffset, token.endOffset); + functionCount++; + } else if (token.tokenType === "symbol" && token.text === ")") { + current += token.text; + + if (hasColon && functionCount == 1) { + if (current) { + result.push({ value: current, type: SELECTOR_PSEUDO_CLASS }); + } + + current = ""; + functionCount--; + hasColon = false; + } else { + functionCount--; + } + } else if (token.tokenType === "symbol" && token.text === "[") { + if (!hasAttribute && !functionCount) { + if (current) { + result.push({ value: current, type: SELECTOR_ELEMENT }); + } + + current = ""; + hasAttribute = true; + } + + current += token.text; + } else if (token.tokenType === "symbol" && token.text === "]") { + current += token.text; + + if (hasAttribute && !functionCount) { + if (current) { + result.push({ value: current, type: SELECTOR_ATTRIBUTE }); + } + + current = ""; + hasAttribute = false; + } + } else { + current += value.substring(token.startOffset, token.endOffset); + } + } + + if (current) { + result.push({ value: current, type: SELECTOR_ELEMENT }); + } + + return result; +} + +/** + * Expects a single CSS value to be passed as the input and parses the value + * and priority. + * + * @param {Function} isCssPropertyKnown + * A function to check if the CSS property is known. This is either an + * internal server function or from the CssPropertiesFront. + * that are supported by the server. + * @param {String} value + * The value from the text editor. + * @return {Object} an object with 'value' and 'priority' properties. + */ +function parseSingleValue(isCssPropertyKnown, value) { + let declaration = parseDeclarations(isCssPropertyKnown, + "a: " + value + ";")[0]; + return { + value: declaration ? declaration.value : "", + priority: declaration ? declaration.priority : "" + }; +} + +/** + * Convert an angle value to degree. + * + * @param {Number} angleValue The angle value. + * @param {CSS_ANGLEUNIT} angleUnit The angleValue's angle unit. + * @return {Number} An angle value in degree. + */ +function getAngleValueInDegrees(angleValue, angleUnit) { + switch (angleUnit) { + case CSS_ANGLEUNIT.deg: + return angleValue; + case CSS_ANGLEUNIT.grad: + return angleValue * 0.9; + case CSS_ANGLEUNIT.rad: + return angleValue * 180 / Math.PI; + case CSS_ANGLEUNIT.turn: + return angleValue * 360; + default: + throw new Error("No matched angle unit."); + } +} + +exports.cssTokenizer = cssTokenizer; +exports.cssTokenizerWithLineColumn = cssTokenizerWithLineColumn; +exports.escapeCSSComment = escapeCSSComment; +// unescapeCSSComment is exported for testing. +exports._unescapeCSSComment = unescapeCSSComment; +exports.parseDeclarations = parseDeclarations; +// parseCommentDeclarations is exported for testing. +exports._parseCommentDeclarations = parseCommentDeclarations; +exports.RuleRewriter = RuleRewriter; +exports.parsePseudoClassesAndAttributes = parsePseudoClassesAndAttributes; +exports.parseSingleValue = parseSingleValue; +exports.getAngleValueInDegrees = getAngleValueInDegrees; |