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