tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

rule-rewriter.js (28477B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 // This file holds various CSS parsing and rewriting utilities.
      6 // Some entry points of note are:
      7 // parseDeclarations - parse a CSS rule into declarations
      8 // RuleRewriter - rewrite CSS rule text
      9 // parsePseudoClassesAndAttributes - parse selector and extract
     10 //     pseudo-classes
     11 // parseSingleValue - parse a single CSS property value
     12 
     13 "use strict";
     14 
     15 const {
     16  InspectorCSSParserWrapper,
     17 } = require("resource://devtools/shared/css/lexer.js");
     18 const {
     19  COMMENT_PARSING_HEURISTIC_BYPASS_CHAR,
     20  escapeCSSComment,
     21  parseNamedDeclarations,
     22  unescapeCSSComment,
     23 } = require("resource://devtools/shared/css/parsing-utils.js");
     24 
     25 loader.lazyRequireGetter(
     26  this,
     27  "getIndentationFromPrefs",
     28  "resource://devtools/shared/indentation.js",
     29  true
     30 );
     31 
     32 // Used to test whether a newline appears anywhere in some text.
     33 const NEWLINE_RX = /[\r\n]/;
     34 // Used to test whether a bit of text starts an empty comment, either
     35 // an "ordinary" /* ... */ comment, or a "heuristic bypass" comment
     36 // like /*! ... */.
     37 const EMPTY_COMMENT_START_RX = /^\/\*!?[ \r\n\t\f]*$/;
     38 // Used to test whether a bit of text ends an empty comment.
     39 const EMPTY_COMMENT_END_RX = /^[ \r\n\t\f]*\*\//;
     40 // Used to test whether a string starts with a blank line.
     41 const BLANK_LINE_RX = /^[ \t]*(?:\r\n|\n|\r|\f|$)/;
     42 
     43 /**
     44 * Return an object that can be used to rewrite declarations in some
     45 * source text.  The source text and parsing are handled in the same
     46 * way as @see parseNamedDeclarations, with |parseComments| being true.
     47 * Rewriting is done by calling one of the modification functions like
     48 * setPropertyEnabled.  The returned object has the same interface
     49 * as @see RuleModificationList.
     50 *
     51 * An example showing how to disable the 3rd property in a rule:
     52 *
     53 *    let rewriter = new RuleRewriter(win, isCssPropertyKnown, ruleActor,
     54 *                                    ruleActor.authoredText);
     55 *    rewriter.setPropertyEnabled(3, "color", false);
     56 *    rewriter.apply().then(() => { ... the change is made ... });
     57 *
     58 * The exported rewriting methods are |renameProperty|, |setPropertyEnabled|,
     59 * |createProperty|, |setProperty|, and |removeProperty|.  The |apply|
     60 * method can be used to send the edited text to the StyleRuleActor;
     61 * |getDefaultIndentation| is useful for the methods requiring a
     62 * default indentation value; and |getResult| is useful for testing.
     63 *
     64 * Additionally, editing will set the |changedDeclarations| property
     65 * on this object.  This property has the same form as the |changed|
     66 * property of the object returned by |getResult|.
     67 */
     68 class RuleRewriter {
     69  /**
     70   * @class
     71   * @param {Window} win
     72   * @param {Function} isCssPropertyKnown
     73   *        A function to check if the CSS property is known. This is either an
     74   *        internal server function or from the CssPropertiesFront.
     75   *        that are supported by the server. Note that if Bug 1222047
     76   *        is completed then isCssPropertyKnown will not need to be passed in.
     77   *        The CssProperty front will be able to obtained directly from the
     78   *        RuleRewriter.
     79   * @param {StyleRuleFront} rule The style rule to use.  Note that this
     80   *        is only needed by the |apply| and |getDefaultIndentation| methods;
     81   *        and in particular for testing it can be |null|.
     82   * @param {string} inputString The CSS source text to parse and modify.
     83   */
     84  constructor(win, isCssPropertyKnown, rule, inputString) {
     85    this.win = win;
     86    this.rule = rule;
     87    this.isCssPropertyKnown = isCssPropertyKnown;
     88    // The RuleRewriter sends CSS rules as text to the server, but with this modifications
     89    // array, it also sends the list of changes so the server doesn't have to re-parse the
     90    // rule if it needs to track what changed.
     91    this.modifications = [];
     92 
     93    // Keep track of which any declarations we had to rewrite while
     94    // performing the requested action.
     95    this.changedDeclarations = {};
     96 
     97    // If not null, a promise that must be wait upon before |apply| can
     98    // do its work.
     99    this.editPromise = null;
    100 
    101    // If the |defaultIndentation| property is set, then it is used;
    102    // otherwise the RuleRewriter will try to compute the default
    103    // indentation based on the style sheet's text.  This override
    104    // facility is for testing.
    105    this.defaultIndentation = null;
    106 
    107    this.startInitialization(inputString);
    108  }
    109 
    110  /**
    111   * An internal function to initialize the rewriter with a given
    112   * input string.
    113   *
    114   * @param {string} inputString the input to use
    115   */
    116  startInitialization(inputString) {
    117    this.inputString = inputString;
    118    // Whether there are any newlines in the input text.
    119    this.hasNewLine = /[\r\n]/.test(this.inputString);
    120    // The declarations.
    121    this.declarations = parseNamedDeclarations(
    122      this.isCssPropertyKnown,
    123      this.inputString,
    124      true
    125    );
    126    this.decl = null;
    127    this.result = null;
    128  }
    129 
    130  /**
    131   * An internal function to complete initialization and set some
    132   * properties for further processing.
    133   *
    134   * @param {number} index The index of the property to modify
    135   */
    136  completeInitialization(index) {
    137    if (index < 0) {
    138      throw new Error("Invalid index " + index + ". Expected positive integer");
    139    }
    140    // |decl| is the declaration to be rewritten, or null if there is no
    141    // declaration corresponding to |index|.
    142    // |result| is used to accumulate the result text.
    143    if (index < this.declarations.length) {
    144      this.decl = this.declarations[index];
    145      this.result = this.inputString.substring(0, this.decl.offsets[0]);
    146    } else {
    147      this.decl = null;
    148      this.result = this.inputString;
    149    }
    150  }
    151 
    152  /**
    153   * A helper function to compute the indentation of some text.  This
    154   * examines the rule's existing text to guess the indentation to use;
    155   * unlike |getDefaultIndentation|, which examines the entire style
    156   * sheet.
    157   *
    158   * @param {string} string the input text
    159   * @param {number} offset the offset at which to compute the indentation
    160   * @return {string} the indentation at the indicated position
    161   */
    162  getIndentation(string, offset) {
    163    let originalOffset = offset;
    164    for (--offset; offset >= 0; --offset) {
    165      const c = string[offset];
    166      if (c === "\r" || c === "\n" || c === "\f") {
    167        return string.substring(offset + 1, originalOffset);
    168      }
    169      if (c !== " " && c !== "\t") {
    170        // Found some non-whitespace character before we found a newline
    171        // -- let's reset the starting point and keep going, as we saw
    172        // something on the line before the declaration.
    173        originalOffset = offset;
    174      }
    175    }
    176    // Ran off the end.
    177    return "";
    178  }
    179 
    180  /**
    181   * Modify a property value to ensure it is "lexically safe" for
    182   * insertion into a style sheet.  This function doesn't attempt to
    183   * ensure that the resulting text is a valid value for the given
    184   * property; but rather just that inserting the text into the style
    185   * sheet will not cause unwanted changes to other rules or
    186   * declarations.
    187   *
    188   * @param {string} text The input text.  This should include the trailing ";".
    189   * @return {Array} An array of the form [anySanitized, text], where
    190   *                 |anySanitized| is a boolean that indicates
    191   *                  whether anything substantive has changed; and
    192   *                  where |text| is the text that has been rewritten
    193   *                  to be "lexically safe".
    194   */
    195  sanitizePropertyValue(text) {
    196    // Start by stripping any trailing ";".  This is done here to
    197    // avoid the case where the user types "url(" (which is turned
    198    // into "url(;" by the rule view before coming here), being turned
    199    // into "url(;)" by this code -- due to the way "url(...)" is
    200    // parsed as a single token.
    201    text = text.replace(/;$/, "");
    202    const lexer = new InspectorCSSParserWrapper(text, { trackEOFChars: true });
    203 
    204    let result = "";
    205    let previousOffset = 0;
    206    const parenStack = [];
    207    let anySanitized = false;
    208 
    209    // Push a closing paren on the stack.
    210    const pushParen = (token, closer) => {
    211      result =
    212        result +
    213        text.substring(previousOffset, token.startOffset) +
    214        text.substring(token.startOffset, token.endOffset);
    215      // We set the location of the paren in a funny way, to handle
    216      // the case where we've seen a function token, where the paren
    217      // appears at the end.
    218      parenStack.push({ closer, offset: result.length - 1, token });
    219      previousOffset = token.endOffset;
    220    };
    221 
    222    // Pop a closing paren from the stack.
    223    const popSomeParens = closer => {
    224      while (parenStack.length) {
    225        const paren = parenStack.pop();
    226 
    227        if (paren.closer === closer) {
    228          return true;
    229        }
    230 
    231        // We need to handle non-closed url function differently, as performEOFFixup will
    232        // only automatically close missing parenthesis `url`.
    233        // In such case, don't do anything here.
    234        if (
    235          paren.closer === ")" &&
    236          closer == null &&
    237          paren.token.tokenType === "Function" &&
    238          paren.token.value === "url"
    239        ) {
    240          return true;
    241        }
    242 
    243        // Found a non-matching closing paren, so quote it.  Note that
    244        // these are processed in reverse order.
    245        result =
    246          result.substring(0, paren.offset) +
    247          "\\" +
    248          result.substring(paren.offset);
    249        anySanitized = true;
    250      }
    251      return false;
    252    };
    253 
    254    let token;
    255    while ((token = lexer.nextToken())) {
    256      switch (token.tokenType) {
    257        case "Semicolon":
    258          // We simply drop the ";" here.  This lets us cope with
    259          // declarations that don't have a ";" and also other
    260          // termination.  The caller handles adding the ";" again.
    261          result += text.substring(previousOffset, token.startOffset);
    262          previousOffset = token.endOffset;
    263          break;
    264 
    265        case "CurlyBracketBlock":
    266          pushParen(token, "}");
    267          break;
    268 
    269        case "ParenthesisBlock":
    270        case "Function":
    271          pushParen(token, ")");
    272          break;
    273 
    274        case "SquareBracketBlock":
    275          pushParen(token, "]");
    276          break;
    277 
    278        case "CloseCurlyBracket":
    279        case "CloseParenthesis":
    280        case "CloseSquareBracket":
    281          // Did we find an unmatched close bracket?
    282          if (!popSomeParens(token.text)) {
    283            // Copy out text from |previousOffset|.
    284            result += text.substring(previousOffset, token.startOffset);
    285            // Quote the offending symbol.
    286            result += "\\" + token.text;
    287            previousOffset = token.endOffset;
    288            anySanitized = true;
    289          }
    290          break;
    291      }
    292    }
    293 
    294    // Fix up any unmatched parens.
    295    popSomeParens(null);
    296 
    297    // Copy out any remaining text, then any needed terminators.
    298    result += text.substring(previousOffset, text.length);
    299 
    300    const eofFixup = lexer.performEOFFixup("");
    301    if (eofFixup) {
    302      anySanitized = true;
    303      result += eofFixup;
    304    }
    305    return [anySanitized, result];
    306  }
    307 
    308  /**
    309   * Start at |index| and skip whitespace
    310   * backward in |string|.  Return the index of the first
    311   * non-whitespace character, or -1 if the entire string was
    312   * whitespace.
    313   *
    314   * @param {string} string the input string
    315   * @param {number} index the index at which to start
    316   * @return {number} index of the first non-whitespace character, or -1
    317   */
    318  skipWhitespaceBackward(string, index) {
    319    for (
    320      --index;
    321      index >= 0 && (string[index] === " " || string[index] === "\t");
    322      --index
    323    ) {
    324      // Nothing.
    325    }
    326    return index;
    327  }
    328 
    329  /**
    330   * Terminate a given declaration, if needed.
    331   *
    332   * @param {number} index The index of the rule to possibly
    333   *                       terminate.  It might be invalid, so this
    334   *                       function must check for that.
    335   */
    336  maybeTerminateDecl(index) {
    337    if (
    338      index < 0 ||
    339      index >= this.declarations.length ||
    340      // No need to rewrite declarations in comments.
    341      "commentOffsets" in this.declarations[index]
    342    ) {
    343      return;
    344    }
    345 
    346    const termDecl = this.declarations[index];
    347    let endIndex = termDecl.offsets[1];
    348    // Due to an oddity of the lexer, we might have gotten a bit of
    349    // extra whitespace in a trailing bad_url token -- so be sure to
    350    // skip that as well.
    351    endIndex = this.skipWhitespaceBackward(this.result, endIndex) + 1;
    352 
    353    const trailingText = this.result.substring(endIndex);
    354    if (termDecl.terminator) {
    355      // Insert the terminator just at the end of the declaration,
    356      // before any trailing whitespace.
    357      this.result =
    358        this.result.substring(0, endIndex) + termDecl.terminator + trailingText;
    359      // In a couple of cases, we may have had to add something to
    360      // terminate the declaration, but the termination did not
    361      // actually affect the property's value -- and at this spot, we
    362      // only care about reporting value changes.  In particular, we
    363      // might have added a plain ";", or we might have terminated a
    364      // comment with "*/;".  Neither of these affect the value.
    365      if (termDecl.terminator !== ";" && termDecl.terminator !== "*/;") {
    366        this.changedDeclarations[index] =
    367          termDecl.value + termDecl.terminator.slice(0, -1);
    368      }
    369    }
    370    // If the rule generally has newlines, but this particular
    371    // declaration doesn't have a trailing newline, insert one now.
    372    // Maybe this style is too weird to bother with.
    373    if (this.hasNewLine && !NEWLINE_RX.test(trailingText)) {
    374      this.result += "\n";
    375    }
    376  }
    377 
    378  /**
    379   * Sanitize the given property value and return the sanitized form.
    380   * If the property is rewritten during sanitization, make a note in
    381   * |changedDeclarations|.
    382   *
    383   * @param {string} text The property text.
    384   * @param {number} index The index of the property.
    385   * @return {string} The sanitized text.
    386   */
    387  sanitizeText(text, index) {
    388    const [anySanitized, sanitizedText] = this.sanitizePropertyValue(text);
    389    if (anySanitized) {
    390      this.changedDeclarations[index] = sanitizedText;
    391    }
    392    return sanitizedText;
    393  }
    394 
    395  /**
    396   * Rename a declaration.
    397   *
    398   * @param {number} index index of the property in the rule.
    399   * @param {string} name current name of the property
    400   * @param {string} newName new name of the property
    401   */
    402  renameProperty(index, name, newName) {
    403    this.completeInitialization(index);
    404    this.result += CSS.escape(newName);
    405    // We could conceivably compute the name offsets instead so we
    406    // could preserve white space and comments on the LHS of the ":".
    407    this.completeCopying(this.decl.colonOffsets[0]);
    408    this.modifications.push({ type: "set", index, name, newName });
    409  }
    410 
    411  /**
    412   * Enable or disable a declaration
    413   *
    414   * @param {number} index index of the property in the rule.
    415   * @param {string} name current name of the property
    416   * @param {boolean} isEnabled true if the property should be enabled;
    417   *                        false if it should be disabled
    418   */
    419  setPropertyEnabled(index, name, isEnabled) {
    420    this.completeInitialization(index);
    421    const decl = this.decl;
    422    const priority = decl.priority;
    423    let copyOffset = decl.offsets[1];
    424    if (isEnabled) {
    425      // Enable it.  First see if the comment start can be deleted.
    426      const commentStart = decl.commentOffsets[0];
    427      if (EMPTY_COMMENT_START_RX.test(this.result.substring(commentStart))) {
    428        this.result = this.result.substring(0, commentStart);
    429      } else {
    430        this.result += "*/ ";
    431      }
    432 
    433      // Insert the name and value separately, so we can report
    434      // sanitization changes properly.
    435      const commentNamePart = this.inputString.substring(
    436        decl.offsets[0],
    437        decl.colonOffsets[1]
    438      );
    439      this.result += unescapeCSSComment(commentNamePart);
    440 
    441      // When uncommenting, we must be sure to sanitize the text, to
    442      // avoid things like /* decl: }; */, which will be accepted as
    443      // a property but which would break the entire style sheet.
    444      let newText = this.inputString.substring(
    445        decl.colonOffsets[1],
    446        decl.offsets[1]
    447      );
    448      newText = cssTrimRight(unescapeCSSComment(newText));
    449      this.result += this.sanitizeText(newText, index) + ";";
    450 
    451      // See if the comment end can be deleted.
    452      const trailingText = this.inputString.substring(decl.offsets[1]);
    453      if (EMPTY_COMMENT_END_RX.test(trailingText)) {
    454        copyOffset = decl.commentOffsets[1];
    455      } else {
    456        this.result += " /*";
    457      }
    458    } else {
    459      // Disable it.  Note that we use our special comment syntax
    460      // here.
    461      const declText = this.inputString.substring(
    462        decl.offsets[0],
    463        decl.offsets[1]
    464      );
    465      this.result +=
    466        "/*" +
    467        COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
    468        " " +
    469        escapeCSSComment(declText) +
    470        " */";
    471    }
    472    this.completeCopying(copyOffset);
    473 
    474    if (isEnabled) {
    475      this.modifications.push({
    476        type: "set",
    477        index,
    478        name,
    479        value: decl.value,
    480        priority,
    481      });
    482    } else {
    483      this.modifications.push({ type: "disable", index, name });
    484    }
    485  }
    486 
    487  /**
    488   * Return a promise that will be resolved to the default indentation
    489   * of the rule.  This is a helper for internalCreateProperty.
    490   *
    491   * @return {Promise} a promise that will be resolved to a string
    492   *         that holds the default indentation that should be used
    493   *         for edits to the rule.
    494   */
    495  async getDefaultIndentation() {
    496    const prefIndent = getIndentationFromPrefs();
    497    if (prefIndent) {
    498      const { indentUnit, indentWithTabs } = prefIndent;
    499      return indentWithTabs ? "\t" : " ".repeat(indentUnit);
    500    }
    501 
    502    const styleSheetsFront =
    503      await this.rule.targetFront.getFront("stylesheets");
    504 
    505    if (!this.rule.parentStyleSheet) {
    506      // See Bug 1899341, due to resource throttling, the parentStyleSheet for
    507      // the rule might not be received by the client yet. Fallback to a usable
    508      // default value.
    509      console.error(
    510        "Cannot retrieve default indentation for rule if parentStyleSheet is not attached yet, falling back to 2 spaces"
    511      );
    512      return "  ";
    513    }
    514 
    515    const styleSheetResourceId = this.rule.parentStyleSheet.resourceId;
    516    return styleSheetsFront.getStyleSheetIndentation(styleSheetResourceId);
    517  }
    518 
    519  /**
    520   * An internal function to create a new declaration.  This does all
    521   * the work of |createProperty|.
    522   *
    523   * @param {number} index index of the property in the rule.
    524   * @param {string} name name of the new property
    525   * @param {string} value value of the new property
    526   * @param {string} priority priority of the new property; either
    527   *                          the empty string or "important"
    528   * @param {boolean} enabled True if the new property should be
    529   *                          enabled, false if disabled
    530   * @return {Promise} a promise that is resolved when the edit has
    531   *                   completed
    532   */
    533  async internalCreateProperty(index, name, value, priority, enabled) {
    534    this.completeInitialization(index);
    535    let newIndentation = "";
    536    if (this.hasNewLine) {
    537      if (this.declarations.length) {
    538        newIndentation = this.getIndentation(
    539          this.inputString,
    540          this.declarations[0].offsets[0]
    541        );
    542      } else if (this.defaultIndentation) {
    543        newIndentation = this.defaultIndentation;
    544      } else {
    545        newIndentation = await this.getDefaultIndentation();
    546      }
    547    }
    548 
    549    this.maybeTerminateDecl(index - 1);
    550 
    551    // If we generally have newlines, and if skipping whitespace
    552    // backward stops at a newline, then insert our text before that
    553    // whitespace.  This ensures the indentation we computed is what
    554    // is actually used.
    555    let savedWhitespace = "";
    556    if (this.hasNewLine) {
    557      const wsOffset = this.skipWhitespaceBackward(
    558        this.result,
    559        this.result.length
    560      );
    561      if (this.result[wsOffset] === "\r" || this.result[wsOffset] === "\n") {
    562        savedWhitespace = this.result.substring(wsOffset + 1);
    563        this.result = this.result.substring(0, wsOffset + 1);
    564      }
    565    }
    566 
    567    let newText = CSS.escape(name) + ": " + this.sanitizeText(value, index);
    568    if (priority === "important") {
    569      newText += " !important";
    570    }
    571    newText += ";";
    572 
    573    if (!enabled) {
    574      newText =
    575        "/*" +
    576        COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
    577        " " +
    578        escapeCSSComment(newText) +
    579        " */";
    580    }
    581 
    582    newText = `${newIndentation}${newText}${this.hasNewLine ? "\n" : ""}${savedWhitespace}`;
    583 
    584    // If the rule has some nested declarations, we need to find the proper index where
    585    // to put the new declaration at.
    586    // e.g. if we have `body { color: red; &>span {}; }`, we want to put the new property
    587    // after `color: red` but before `&>span`.
    588    let insertIndex = -1;
    589    // Don't try to find the index if we can already see there's no nested rules
    590    if (this.result.includes("{")) {
    591      // Create a rule with the initial rule text so we can check for children rules
    592      const dummySheet = new this.win.CSSStyleSheet();
    593      dummySheet.replaceSync(":root {\n" + this.result + "}");
    594      const dummyRule = dummySheet.cssRules[0];
    595      if (dummyRule.cssRules.length) {
    596        const nestedRule = dummyRule.cssRules[0];
    597        const nestedRuleLine = InspectorUtils.getRelativeRuleLine(nestedRule);
    598        const nestedRuleColumn = InspectorUtils.getRuleColumn(nestedRule);
    599        // We need to account for the new line we added for the parent rule,
    600        // and then remove 1 again since the InspectorUtils method returns 1-based values
    601        let actualLine = nestedRuleLine - 2;
    602        const actualColumn = nestedRuleColumn - 1;
    603 
    604        // First, we compute the index in the original rule text corresponding to the
    605        // nested rule line number.
    606        insertIndex = 0;
    607        for (
    608          ;
    609          insertIndex < this.result.length && actualLine > 0;
    610          insertIndex++
    611        ) {
    612          if (this.result[insertIndex] === "\n") {
    613            actualLine--;
    614          }
    615        }
    616 
    617        // If the property doesn't add a new line, we need to insert the declaration
    618        // before the nested declaration. When the property does add a new line,
    619        // insertIndex already has the correct position.
    620        if (!this.hasNewLine) {
    621          insertIndex += actualColumn;
    622        }
    623      }
    624    }
    625 
    626    if (insertIndex == -1) {
    627      this.result += newText;
    628    } else {
    629      this.result =
    630        this.result.substring(0, insertIndex) +
    631        newText +
    632        this.result.substring(insertIndex);
    633    }
    634 
    635    if (this.decl) {
    636      // Still want to copy in the declaration previously at this index.
    637      this.completeCopying(this.decl.offsets[0]);
    638    }
    639  }
    640 
    641  /**
    642   * Create a new declaration.
    643   *
    644   * @param {number} index index of the property in the rule.
    645   * @param {string} name name of the new property
    646   * @param {string} value value of the new property
    647   * @param {string} priority priority of the new property; either
    648   *                          the empty string or "important"
    649   * @param {boolean} enabled True if the new property should be
    650   *                          enabled, false if disabled
    651   */
    652  createProperty(index, name, value, priority, enabled) {
    653    this.editPromise = this.internalCreateProperty(
    654      index,
    655      name,
    656      value,
    657      priority,
    658      enabled
    659    );
    660    // Log the modification only if the created property is enabled.
    661    if (enabled) {
    662      this.modifications.push({ type: "set", index, name, value, priority });
    663    }
    664  }
    665 
    666  /**
    667   * Set a declaration's value.
    668   *
    669   * @param {number} index index of the property in the rule.
    670   *                       This can be -1 in the case where
    671   *                       the rule does not support setRuleText;
    672   *                       generally for setting properties
    673   *                       on an element's style.
    674   * @param {string} name the property's name
    675   * @param {string} value the property's value
    676   * @param {string} priority the property's priority, either the empty
    677   *                          string or "important"
    678   */
    679  setProperty(index, name, value, priority) {
    680    this.completeInitialization(index);
    681    // We might see a "set" on a previously non-existent property; in
    682    // that case, act like "create".
    683    if (!this.decl) {
    684      this.createProperty(index, name, value, priority, true);
    685      return;
    686    }
    687 
    688    // Note that this assumes that "set" never operates on disabled
    689    // properties.
    690    this.result +=
    691      this.inputString.substring(
    692        this.decl.offsets[0],
    693        this.decl.colonOffsets[1]
    694      ) + this.sanitizeText(value, index);
    695 
    696    if (priority === "important") {
    697      this.result += " !important";
    698    }
    699    this.result += ";";
    700    this.completeCopying(this.decl.offsets[1]);
    701    this.modifications.push({ type: "set", index, name, value, priority });
    702  }
    703 
    704  /**
    705   * Remove a declaration.
    706   *
    707   * @param {number} index index of the property in the rule.
    708   * @param {string} name the name of the property to remove
    709   */
    710  removeProperty(index, name) {
    711    this.completeInitialization(index);
    712 
    713    // If asked to remove a property that does not exist, bail out.
    714    if (!this.decl) {
    715      return;
    716    }
    717 
    718    // If the property is disabled, then first enable it, and then
    719    // delete it.  We take this approach because we want to remove the
    720    // entire comment if possible; but the logic for dealing with
    721    // comments is hairy and already implemented in
    722    // setPropertyEnabled.
    723    if (this.decl.commentOffsets) {
    724      this.setPropertyEnabled(index, name, true);
    725      this.startInitialization(this.result);
    726      this.completeInitialization(index);
    727    }
    728 
    729    let copyOffset = this.decl.offsets[1];
    730    // Maybe removing this rule left us with a completely blank
    731    // line.  In this case, we'll delete the whole thing.  We only
    732    // bother with this if we're looking at sources that already
    733    // have a newline somewhere.
    734    if (this.hasNewLine) {
    735      const nlOffset = this.skipWhitespaceBackward(
    736        this.result,
    737        this.decl.offsets[0]
    738      );
    739      if (
    740        nlOffset < 0 ||
    741        this.result[nlOffset] === "\r" ||
    742        this.result[nlOffset] === "\n"
    743      ) {
    744        const trailingText = this.inputString.substring(copyOffset);
    745        const match = BLANK_LINE_RX.exec(trailingText);
    746        if (match) {
    747          this.result = this.result.substring(0, nlOffset + 1);
    748          copyOffset += match[0].length;
    749        }
    750      }
    751    }
    752    this.completeCopying(copyOffset);
    753    this.modifications.push({ type: "remove", index, name });
    754  }
    755 
    756  /**
    757   * An internal function to copy any trailing text to the output
    758   * string.
    759   *
    760   * @param {number} copyOffset Offset into |inputString| of the
    761   *        final text to copy to the output string.
    762   */
    763  completeCopying(copyOffset) {
    764    // Add the trailing text.
    765    this.result += this.inputString.substring(copyOffset);
    766  }
    767 
    768  /**
    769   * Apply the modifications in this object to the associated rule.
    770   *
    771   * @return {Promise} A promise which will be resolved when the modifications
    772   *         are complete.
    773   */
    774  apply() {
    775    return Promise.resolve(this.editPromise).then(() => {
    776      return this.rule.setRuleText(this.result, this.modifications);
    777    });
    778  }
    779 
    780  /**
    781   * Get the result of the rewriting.  This is used for testing.
    782   *
    783   * @return {object} an object of the form {changed: object, text: string}
    784   *                  |changed| is an object where each key is
    785   *                  the index of a property whose value had to be
    786   *                  rewritten during the sanitization process, and
    787   *                  whose value is the new text of the property.
    788   *                  |text| is the rewritten text of the rule.
    789   */
    790  getResult() {
    791    return { changed: this.changedDeclarations, text: this.result };
    792  }
    793 }
    794 
    795 /**
    796 * Like trimRight, but only trims CSS-allowed whitespace.
    797 */
    798 function cssTrimRight(str) {
    799  const match = /^(.*?)[ \t\r\n\f]*$/.exec(str);
    800  if (match) {
    801    return match[1];
    802  }
    803  return str;
    804 }
    805 
    806 module.exports = RuleRewriter;