Bug 1924993 - [devtools] Debugger tests wait before typing in conditional panel r...
[gecko.git] / devtools / client / fronts / inspector / rule-rewriter.js
blob2bde4d0ea9b532796edafb2ea341447adec50c8d
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/. */
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
13 "use strict";
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");
25 loader.lazyRequireGetter(
26 this,
27 ["getIndentationFromPrefs", "getIndentationFromString"],
28 "resource://devtools/shared/indentation.js",
29 true
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|$)/;
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.
51 * An example showing how to disable the 3rd property in a rule:
53 * let rewriter = new RuleRewriter(isCssPropertyKnown, ruleActor,
54 * ruleActor.authoredText);
55 * rewriter.setPropertyEnabled(3, "color", false);
56 * rewriter.apply().then(() => { ... the change is made ... });
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.
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|.
68 * @param {Function} isCssPropertyKnown
69 * A function to check if the CSS property is known. This is either an
70 * internal server function or from the CssPropertiesFront.
71 * that are supported by the server. Note that if Bug 1222047
72 * is completed then isCssPropertyKnown will not need to be passed in.
73 * The CssProperty front will be able to obtained directly from the
74 * RuleRewriter.
75 * @param {StyleRuleFront} rule The style rule to use. Note that this
76 * is only needed by the |apply| and |getDefaultIndentation| methods;
77 * and in particular for testing it can be |null|.
78 * @param {String} inputString The CSS source text to parse and modify.
79 * @return {Object} an object that can be used to rewrite the input text.
81 function RuleRewriter(isCssPropertyKnown, rule, inputString) {
82 this.rule = rule;
83 this.isCssPropertyKnown = isCssPropertyKnown;
84 // The RuleRewriter sends CSS rules as text to the server, but with this modifications
85 // array, it also sends the list of changes so the server doesn't have to re-parse the
86 // rule if it needs to track what changed.
87 this.modifications = [];
89 // Keep track of which any declarations we had to rewrite while
90 // performing the requested action.
91 this.changedDeclarations = {};
93 // If not null, a promise that must be wait upon before |apply| can
94 // do its work.
95 this.editPromise = null;
97 // If the |defaultIndentation| property is set, then it is used;
98 // otherwise the RuleRewriter will try to compute the default
99 // indentation based on the style sheet's text. This override
100 // facility is for testing.
101 this.defaultIndentation = null;
103 this.startInitialization(inputString);
106 RuleRewriter.prototype = {
108 * An internal function to initialize the rewriter with a given
109 * input string.
111 * @param {String} inputString the input to use
113 startInitialization(inputString) {
114 this.inputString = inputString;
115 // Whether there are any newlines in the input text.
116 this.hasNewLine = /[\r\n]/.test(this.inputString);
117 // The declarations.
118 this.declarations = parseNamedDeclarations(
119 this.isCssPropertyKnown,
120 this.inputString,
121 true
123 this.decl = null;
124 this.result = null;
128 * An internal function to complete initialization and set some
129 * properties for further processing.
131 * @param {Number} index The index of the property to modify
133 completeInitialization(index) {
134 if (index < 0) {
135 throw new Error("Invalid index " + index + ". Expected positive integer");
137 // |decl| is the declaration to be rewritten, or null if there is no
138 // declaration corresponding to |index|.
139 // |result| is used to accumulate the result text.
140 if (index < this.declarations.length) {
141 this.decl = this.declarations[index];
142 this.result = this.inputString.substring(0, this.decl.offsets[0]);
143 } else {
144 this.decl = null;
145 this.result = this.inputString;
150 * A helper function to compute the indentation of some text. This
151 * examines the rule's existing text to guess the indentation to use;
152 * unlike |getDefaultIndentation|, which examines the entire style
153 * sheet.
155 * @param {String} string the input text
156 * @param {Number} offset the offset at which to compute the indentation
157 * @return {String} the indentation at the indicated position
159 getIndentation(string, offset) {
160 let originalOffset = offset;
161 for (--offset; offset >= 0; --offset) {
162 const c = string[offset];
163 if (c === "\r" || c === "\n" || c === "\f") {
164 return string.substring(offset + 1, originalOffset);
166 if (c !== " " && c !== "\t") {
167 // Found some non-whitespace character before we found a newline
168 // -- let's reset the starting point and keep going, as we saw
169 // something on the line before the declaration.
170 originalOffset = offset;
173 // Ran off the end.
174 return "";
178 * Modify a property value to ensure it is "lexically safe" for
179 * insertion into a style sheet. This function doesn't attempt to
180 * ensure that the resulting text is a valid value for the given
181 * property; but rather just that inserting the text into the style
182 * sheet will not cause unwanted changes to other rules or
183 * declarations.
185 * @param {String} text The input text. This should include the trailing ";".
186 * @return {Array} An array of the form [anySanitized, text], where
187 * |anySanitized| is a boolean that indicates
188 * whether anything substantive has changed; and
189 * where |text| is the text that has been rewritten
190 * to be "lexically safe".
192 sanitizePropertyValue(text) {
193 // Start by stripping any trailing ";". This is done here to
194 // avoid the case where the user types "url(" (which is turned
195 // into "url(;" by the rule view before coming here), being turned
196 // into "url(;)" by this code -- due to the way "url(...)" is
197 // parsed as a single token.
198 text = text.replace(/;$/, "");
199 const lexer = new InspectorCSSParserWrapper(text, { trackEOFChars: true });
201 let result = "";
202 let previousOffset = 0;
203 const parenStack = [];
204 let anySanitized = false;
206 // Push a closing paren on the stack.
207 const pushParen = (token, closer) => {
208 result =
209 result +
210 text.substring(previousOffset, token.startOffset) +
211 text.substring(token.startOffset, token.endOffset);
212 // We set the location of the paren in a funny way, to handle
213 // the case where we've seen a function token, where the paren
214 // appears at the end.
215 parenStack.push({ closer, offset: result.length - 1, token });
216 previousOffset = token.endOffset;
219 // Pop a closing paren from the stack.
220 const popSomeParens = closer => {
221 while (parenStack.length) {
222 const paren = parenStack.pop();
224 if (paren.closer === closer) {
225 return true;
228 // We need to handle non-closed url function differently, as performEOFFixup will
229 // only automatically close missing parenthesis `url`.
230 // In such case, don't do anything here.
231 if (
232 paren.closer === ")" &&
233 closer == null &&
234 paren.token.tokenType === "Function" &&
235 paren.token.value === "url"
237 return true;
240 // Found a non-matching closing paren, so quote it. Note that
241 // these are processed in reverse order.
242 result =
243 result.substring(0, paren.offset) +
244 "\\" +
245 result.substring(paren.offset);
246 anySanitized = true;
248 return false;
251 let token;
252 while ((token = lexer.nextToken())) {
253 switch (token.tokenType) {
254 case "Semicolon":
255 // We simply drop the ";" here. This lets us cope with
256 // declarations that don't have a ";" and also other
257 // termination. The caller handles adding the ";" again.
258 result += text.substring(previousOffset, token.startOffset);
259 previousOffset = token.endOffset;
260 break;
262 case "CurlyBracketBlock":
263 pushParen(token, "}");
264 break;
266 case "ParenthesisBlock":
267 case "Function":
268 pushParen(token, ")");
269 break;
271 case "SquareBracketBlock":
272 pushParen(token, "]");
273 break;
275 case "CloseCurlyBracket":
276 case "CloseParenthesis":
277 case "CloseSquareBracket":
278 // Did we find an unmatched close bracket?
279 if (!popSomeParens(token.text)) {
280 // Copy out text from |previousOffset|.
281 result += text.substring(previousOffset, token.startOffset);
282 // Quote the offending symbol.
283 result += "\\" + token.text;
284 previousOffset = token.endOffset;
285 anySanitized = true;
287 break;
291 // Fix up any unmatched parens.
292 popSomeParens(null);
294 // Copy out any remaining text, then any needed terminators.
295 result += text.substring(previousOffset, text.length);
297 const eofFixup = lexer.performEOFFixup("");
298 if (eofFixup) {
299 anySanitized = true;
300 result += eofFixup;
302 return [anySanitized, result];
306 * Start at |index| and skip whitespace
307 * backward in |string|. Return the index of the first
308 * non-whitespace character, or -1 if the entire string was
309 * whitespace.
310 * @param {String} string the input string
311 * @param {Number} index the index at which to start
312 * @return {Number} index of the first non-whitespace character, or -1
314 skipWhitespaceBackward(string, index) {
315 for (
316 --index;
317 index >= 0 && (string[index] === " " || string[index] === "\t");
318 --index
320 // Nothing.
322 return index;
326 * Terminate a given declaration, if needed.
328 * @param {Number} index The index of the rule to possibly
329 * terminate. It might be invalid, so this
330 * function must check for that.
332 maybeTerminateDecl(index) {
333 if (
334 index < 0 ||
335 index >= this.declarations.length ||
336 // No need to rewrite declarations in comments.
337 "commentOffsets" in this.declarations[index]
339 return;
342 const termDecl = this.declarations[index];
343 let endIndex = termDecl.offsets[1];
344 // Due to an oddity of the lexer, we might have gotten a bit of
345 // extra whitespace in a trailing bad_url token -- so be sure to
346 // skip that as well.
347 endIndex = this.skipWhitespaceBackward(this.result, endIndex) + 1;
349 const trailingText = this.result.substring(endIndex);
350 if (termDecl.terminator) {
351 // Insert the terminator just at the end of the declaration,
352 // before any trailing whitespace.
353 this.result =
354 this.result.substring(0, endIndex) + termDecl.terminator + trailingText;
355 // In a couple of cases, we may have had to add something to
356 // terminate the declaration, but the termination did not
357 // actually affect the property's value -- and at this spot, we
358 // only care about reporting value changes. In particular, we
359 // might have added a plain ";", or we might have terminated a
360 // comment with "*/;". Neither of these affect the value.
361 if (termDecl.terminator !== ";" && termDecl.terminator !== "*/;") {
362 this.changedDeclarations[index] =
363 termDecl.value + termDecl.terminator.slice(0, -1);
366 // If the rule generally has newlines, but this particular
367 // declaration doesn't have a trailing newline, insert one now.
368 // Maybe this style is too weird to bother with.
369 if (this.hasNewLine && !NEWLINE_RX.test(trailingText)) {
370 this.result += "\n";
375 * Sanitize the given property value and return the sanitized form.
376 * If the property is rewritten during sanitization, make a note in
377 * |changedDeclarations|.
379 * @param {String} text The property text.
380 * @param {Number} index The index of the property.
381 * @return {String} The sanitized text.
383 sanitizeText(text, index) {
384 const [anySanitized, sanitizedText] = this.sanitizePropertyValue(text);
385 if (anySanitized) {
386 this.changedDeclarations[index] = sanitizedText;
388 return sanitizedText;
392 * Rename a declaration.
394 * @param {Number} index index of the property in the rule.
395 * @param {String} name current name of the property
396 * @param {String} newName new name of the property
398 renameProperty(index, name, newName) {
399 this.completeInitialization(index);
400 this.result += CSS.escape(newName);
401 // We could conceivably compute the name offsets instead so we
402 // could preserve white space and comments on the LHS of the ":".
403 this.completeCopying(this.decl.colonOffsets[0]);
404 this.modifications.push({ type: "set", index, name, newName });
408 * Enable or disable a declaration
410 * @param {Number} index index of the property in the rule.
411 * @param {String} name current name of the property
412 * @param {Boolean} isEnabled true if the property should be enabled;
413 * false if it should be disabled
415 setPropertyEnabled(index, name, isEnabled) {
416 this.completeInitialization(index);
417 const decl = this.decl;
418 const priority = decl.priority;
419 let copyOffset = decl.offsets[1];
420 if (isEnabled) {
421 // Enable it. First see if the comment start can be deleted.
422 const commentStart = decl.commentOffsets[0];
423 if (EMPTY_COMMENT_START_RX.test(this.result.substring(commentStart))) {
424 this.result = this.result.substring(0, commentStart);
425 } else {
426 this.result += "*/ ";
429 // Insert the name and value separately, so we can report
430 // sanitization changes properly.
431 const commentNamePart = this.inputString.substring(
432 decl.offsets[0],
433 decl.colonOffsets[1]
435 this.result += unescapeCSSComment(commentNamePart);
437 // When uncommenting, we must be sure to sanitize the text, to
438 // avoid things like /* decl: }; */, which will be accepted as
439 // a property but which would break the entire style sheet.
440 let newText = this.inputString.substring(
441 decl.colonOffsets[1],
442 decl.offsets[1]
444 newText = cssTrimRight(unescapeCSSComment(newText));
445 this.result += this.sanitizeText(newText, index) + ";";
447 // See if the comment end can be deleted.
448 const trailingText = this.inputString.substring(decl.offsets[1]);
449 if (EMPTY_COMMENT_END_RX.test(trailingText)) {
450 copyOffset = decl.commentOffsets[1];
451 } else {
452 this.result += " /*";
454 } else {
455 // Disable it. Note that we use our special comment syntax
456 // here.
457 const declText = this.inputString.substring(
458 decl.offsets[0],
459 decl.offsets[1]
461 this.result +=
462 "/*" +
463 COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
464 " " +
465 escapeCSSComment(declText) +
466 " */";
468 this.completeCopying(copyOffset);
470 if (isEnabled) {
471 this.modifications.push({
472 type: "set",
473 index,
474 name,
475 value: decl.value,
476 priority,
478 } else {
479 this.modifications.push({ type: "disable", index, name });
484 * Return a promise that will be resolved to the default indentation
485 * of the rule. This is a helper for internalCreateProperty.
487 * @return {Promise} a promise that will be resolved to a string
488 * that holds the default indentation that should be used
489 * for edits to the rule.
491 async getDefaultIndentation() {
492 const prefIndent = getIndentationFromPrefs();
493 if (prefIndent) {
494 const { indentUnit, indentWithTabs } = prefIndent;
495 return indentWithTabs ? "\t" : " ".repeat(indentUnit);
498 const styleSheetsFront =
499 await this.rule.targetFront.getFront("stylesheets");
501 if (!this.rule.parentStyleSheet) {
502 // See Bug 1899341, due to resource throttling, the parentStyleSheet for
503 // the rule might not be received by the client yet. Fallback to a usable
504 // default value.
505 console.error(
506 "Cannot retrieve default indentation for rule if parentStyleSheet is not attached yet, falling back to 2 spaces"
508 return " ";
510 const { str: source } = await styleSheetsFront.getText(
511 this.rule.parentStyleSheet.resourceId
513 const { indentUnit, indentWithTabs } = getIndentationFromString(source);
514 return indentWithTabs ? "\t" : " ".repeat(indentUnit);
518 * An internal function to create a new declaration. This does all
519 * the work of |createProperty|.
521 * @param {Number} index index of the property in the rule.
522 * @param {String} name name of the new property
523 * @param {String} value value of the new property
524 * @param {String} priority priority of the new property; either
525 * the empty string or "important"
526 * @param {Boolean} enabled True if the new property should be
527 * enabled, false if disabled
528 * @return {Promise} a promise that is resolved when the edit has
529 * completed
531 async internalCreateProperty(index, name, value, priority, enabled) {
532 this.completeInitialization(index);
533 let newIndentation = "";
534 if (this.hasNewLine) {
535 if (this.declarations.length) {
536 newIndentation = this.getIndentation(
537 this.inputString,
538 this.declarations[0].offsets[0]
540 } else if (this.defaultIndentation) {
541 newIndentation = this.defaultIndentation;
542 } else {
543 newIndentation = await this.getDefaultIndentation();
547 this.maybeTerminateDecl(index - 1);
549 // If we generally have newlines, and if skipping whitespace
550 // backward stops at a newline, then insert our text before that
551 // whitespace. This ensures the indentation we computed is what
552 // is actually used.
553 let savedWhitespace = "";
554 if (this.hasNewLine) {
555 const wsOffset = this.skipWhitespaceBackward(
556 this.result,
557 this.result.length
559 if (this.result[wsOffset] === "\r" || this.result[wsOffset] === "\n") {
560 savedWhitespace = this.result.substring(wsOffset + 1);
561 this.result = this.result.substring(0, wsOffset + 1);
565 let newText = CSS.escape(name) + ": " + this.sanitizeText(value, index);
566 if (priority === "important") {
567 newText += " !important";
569 newText += ";";
571 if (!enabled) {
572 newText =
573 "/*" +
574 COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
575 " " +
576 escapeCSSComment(newText) +
577 " */";
580 this.result += newIndentation + newText;
581 if (this.hasNewLine) {
582 this.result += "\n";
584 this.result += savedWhitespace;
586 if (this.decl) {
587 // Still want to copy in the declaration previously at this
588 // index.
589 this.completeCopying(this.decl.offsets[0]);
594 * Create a new declaration.
596 * @param {Number} index index of the property in the rule.
597 * @param {String} name name of the new property
598 * @param {String} value value of the new property
599 * @param {String} priority priority of the new property; either
600 * the empty string or "important"
601 * @param {Boolean} enabled True if the new property should be
602 * enabled, false if disabled
604 createProperty(index, name, value, priority, enabled) {
605 this.editPromise = this.internalCreateProperty(
606 index,
607 name,
608 value,
609 priority,
610 enabled
612 // Log the modification only if the created property is enabled.
613 if (enabled) {
614 this.modifications.push({ type: "set", index, name, value, priority });
619 * Set a declaration's value.
621 * @param {Number} index index of the property in the rule.
622 * This can be -1 in the case where
623 * the rule does not support setRuleText;
624 * generally for setting properties
625 * on an element's style.
626 * @param {String} name the property's name
627 * @param {String} value the property's value
628 * @param {String} priority the property's priority, either the empty
629 * string or "important"
631 setProperty(index, name, value, priority) {
632 this.completeInitialization(index);
633 // We might see a "set" on a previously non-existent property; in
634 // that case, act like "create".
635 if (!this.decl) {
636 this.createProperty(index, name, value, priority, true);
637 return;
640 // Note that this assumes that "set" never operates on disabled
641 // properties.
642 this.result +=
643 this.inputString.substring(
644 this.decl.offsets[0],
645 this.decl.colonOffsets[1]
646 ) + this.sanitizeText(value, index);
648 if (priority === "important") {
649 this.result += " !important";
651 this.result += ";";
652 this.completeCopying(this.decl.offsets[1]);
653 this.modifications.push({ type: "set", index, name, value, priority });
657 * Remove a declaration.
659 * @param {Number} index index of the property in the rule.
660 * @param {String} name the name of the property to remove
662 removeProperty(index, name) {
663 this.completeInitialization(index);
665 // If asked to remove a property that does not exist, bail out.
666 if (!this.decl) {
667 return;
670 // If the property is disabled, then first enable it, and then
671 // delete it. We take this approach because we want to remove the
672 // entire comment if possible; but the logic for dealing with
673 // comments is hairy and already implemented in
674 // setPropertyEnabled.
675 if (this.decl.commentOffsets) {
676 this.setPropertyEnabled(index, name, true);
677 this.startInitialization(this.result);
678 this.completeInitialization(index);
681 let copyOffset = this.decl.offsets[1];
682 // Maybe removing this rule left us with a completely blank
683 // line. In this case, we'll delete the whole thing. We only
684 // bother with this if we're looking at sources that already
685 // have a newline somewhere.
686 if (this.hasNewLine) {
687 const nlOffset = this.skipWhitespaceBackward(
688 this.result,
689 this.decl.offsets[0]
691 if (
692 nlOffset < 0 ||
693 this.result[nlOffset] === "\r" ||
694 this.result[nlOffset] === "\n"
696 const trailingText = this.inputString.substring(copyOffset);
697 const match = BLANK_LINE_RX.exec(trailingText);
698 if (match) {
699 this.result = this.result.substring(0, nlOffset + 1);
700 copyOffset += match[0].length;
704 this.completeCopying(copyOffset);
705 this.modifications.push({ type: "remove", index, name });
709 * An internal function to copy any trailing text to the output
710 * string.
712 * @param {Number} copyOffset Offset into |inputString| of the
713 * final text to copy to the output string.
715 completeCopying(copyOffset) {
716 // Add the trailing text.
717 this.result += this.inputString.substring(copyOffset);
721 * Apply the modifications in this object to the associated rule.
723 * @return {Promise} A promise which will be resolved when the modifications
724 * are complete.
726 apply() {
727 return Promise.resolve(this.editPromise).then(() => {
728 return this.rule.setRuleText(this.result, this.modifications);
733 * Get the result of the rewriting. This is used for testing.
735 * @return {object} an object of the form {changed: object, text: string}
736 * |changed| is an object where each key is
737 * the index of a property whose value had to be
738 * rewritten during the sanitization process, and
739 * whose value is the new text of the property.
740 * |text| is the rewritten text of the rule.
742 getResult() {
743 return { changed: this.changedDeclarations, text: this.result };
748 * Like trimRight, but only trims CSS-allowed whitespace.
750 function cssTrimRight(str) {
751 const match = /^(.*?)[ \t\r\n\f]*$/.exec(str);
752 if (match) {
753 return match[1];
755 return str;
758 module.exports = RuleRewriter;