1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171 |
- /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
- /* 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;
|