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
11 // parseSingleValue - parse a single CSS property value
16 InspectorCSSParserWrapper
,
17 } = require("resource://devtools/shared/css/lexer.js");
19 COMMENT_PARSING_HEURISTIC_BYPASS_CHAR
,
21 parseNamedDeclarations
,
23 } = require("resource://devtools/shared/css/parsing-utils.js");
25 loader
.lazyRequireGetter(
27 ["getIndentationFromPrefs", "getIndentationFromString"],
28 "resource://devtools/shared/indentation.js",
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
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|$)/;
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
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
) {
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
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
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
);
118 this.declarations
= parseNamedDeclarations(
119 this.isCssPropertyKnown
,
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
) {
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]);
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
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
;
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
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 });
202 let previousOffset
= 0;
203 const parenStack
= [];
204 let anySanitized
= false;
206 // Push a closing paren on the stack.
207 const pushParen
= (token
, closer
) => {
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
) {
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.
232 paren
.closer
=== ")" &&
234 paren
.token
.tokenType
=== "Function" &&
235 paren
.token
.value
=== "url"
240 // Found a non-matching closing paren, so quote it. Note that
241 // these are processed in reverse order.
243 result
.substring(0, paren
.offset
) +
245 result
.substring(paren
.offset
);
252 while ((token
= lexer
.nextToken())) {
253 switch (token
.tokenType
) {
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
;
262 case "CurlyBracketBlock":
263 pushParen(token
, "}");
266 case "ParenthesisBlock":
268 pushParen(token
, ")");
271 case "SquareBracketBlock":
272 pushParen(token
, "]");
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
;
291 // Fix up any unmatched parens.
294 // Copy out any remaining text, then any needed terminators.
295 result
+= text
.substring(previousOffset
, text
.length
);
297 const eofFixup
= lexer
.performEOFFixup("");
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
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
) {
317 index
>= 0 && (string
[index
] === " " || string
[index
] === "\t");
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
) {
335 index
>= this.declarations
.length
||
336 // No need to rewrite declarations in comments.
337 "commentOffsets" in this.declarations
[index
]
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.
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
)) {
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
);
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];
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
);
426 this.result
+= "*/ ";
429 // Insert the name and value separately, so we can report
430 // sanitization changes properly.
431 const commentNamePart
= this.inputString
.substring(
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],
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];
452 this.result
+= " /*";
455 // Disable it. Note that we use our special comment syntax
457 const declText
= this.inputString
.substring(
463 COMMENT_PARSING_HEURISTIC_BYPASS_CHAR
+
465 escapeCSSComment(declText
) +
468 this.completeCopying(copyOffset
);
471 this.modifications
.push({
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();
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
506 "Cannot retrieve default indentation for rule if parentStyleSheet is not attached yet, falling back to 2 spaces"
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
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(
538 this.declarations
[0].offsets
[0]
540 } else if (this.defaultIndentation
) {
541 newIndentation
= this.defaultIndentation
;
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
553 let savedWhitespace
= "";
554 if (this.hasNewLine
) {
555 const wsOffset
= this.skipWhitespaceBackward(
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";
574 COMMENT_PARSING_HEURISTIC_BYPASS_CHAR
+
576 escapeCSSComment(newText
) +
580 this.result
+= newIndentation
+ newText
;
581 if (this.hasNewLine
) {
584 this.result
+= savedWhitespace
;
587 // Still want to copy in the declaration previously at this
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(
612 // Log the modification only if the created property is 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".
636 this.createProperty(index
, name
, value
, priority
, true);
640 // Note that this assumes that "set" never operates on disabled
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";
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.
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(
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
);
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
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
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.
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
);
758 module
.exports
= RuleRewriter
;