parsing-utils.js 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171
  1. /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
  2. /* This Source Code Form is subject to the terms of the Mozilla Public
  3. * License, v. 2.0. If a copy of the MPL was not distributed with this
  4. * 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
  12. "use strict";
  13. const {CSS_ANGLEUNIT} = require("devtools/shared/css/properties-db");
  14. const promise = require("promise");
  15. const {getCSSLexer} = require("devtools/shared/css/lexer");
  16. const {Task} = require("devtools/shared/task");
  17. const SELECTOR_ATTRIBUTE = exports.SELECTOR_ATTRIBUTE = 1;
  18. const SELECTOR_ELEMENT = exports.SELECTOR_ELEMENT = 2;
  19. const SELECTOR_PSEUDO_CLASS = exports.SELECTOR_PSEUDO_CLASS = 3;
  20. // Used to test whether a newline appears anywhere in some text.
  21. const NEWLINE_RX = /[\r\n]/;
  22. // Used to test whether a bit of text starts an empty comment, either
  23. // an "ordinary" /* ... */ comment, or a "heuristic bypass" comment
  24. // like /*! ... */.
  25. const EMPTY_COMMENT_START_RX = /^\/\*!?[ \r\n\t\f]*$/;
  26. // Used to test whether a bit of text ends an empty comment.
  27. const EMPTY_COMMENT_END_RX = /^[ \r\n\t\f]*\*\//;
  28. // Used to test whether a string starts with a blank line.
  29. const BLANK_LINE_RX = /^[ \t]*(?:\r\n|\n|\r|\f|$)/;
  30. // When commenting out a declaration, we put this character into the
  31. // comment opener so that future parses of the commented text know to
  32. // bypass the property name validity heuristic.
  33. const COMMENT_PARSING_HEURISTIC_BYPASS_CHAR = "!";
  34. /**
  35. * A generator function that lexes a CSS source string, yielding the
  36. * CSS tokens. Comment tokens are dropped.
  37. *
  38. * @param {String} CSS source string
  39. * @yield {CSSToken} The next CSSToken that is lexed
  40. * @see CSSToken for details about the returned tokens
  41. */
  42. function* cssTokenizer(string) {
  43. let lexer = getCSSLexer(string);
  44. while (true) {
  45. let token = lexer.nextToken();
  46. if (!token) {
  47. break;
  48. }
  49. // None of the existing consumers want comments.
  50. if (token.tokenType !== "comment") {
  51. yield token;
  52. }
  53. }
  54. }
  55. /**
  56. * Pass |string| to the CSS lexer and return an array of all the
  57. * returned tokens. Comment tokens are not included. In addition to
  58. * the usual information, each token will have starting and ending
  59. * line and column information attached. Specifically, each token
  60. * has an additional "loc" attribute. This attribute is an object
  61. * of the form {line: L, column: C}. Lines and columns are both zero
  62. * based.
  63. *
  64. * It's best not to add new uses of this function. In general it is
  65. * simpler and better to use the CSSToken offsets, rather than line
  66. * and column. Also, this function lexes the entire input string at
  67. * once, rather than lazily yielding a token stream. Use
  68. * |cssTokenizer| or |getCSSLexer| instead.
  69. *
  70. * @param{String} string The input string.
  71. * @return {Array} An array of tokens (@see CSSToken) that have
  72. * line and column information.
  73. */
  74. function cssTokenizerWithLineColumn(string) {
  75. let lexer = getCSSLexer(string);
  76. let result = [];
  77. let prevToken = undefined;
  78. while (true) {
  79. let token = lexer.nextToken();
  80. let lineNumber = lexer.lineNumber;
  81. let columnNumber = lexer.columnNumber;
  82. if (prevToken) {
  83. prevToken.loc.end = {
  84. line: lineNumber,
  85. column: columnNumber
  86. };
  87. }
  88. if (!token) {
  89. break;
  90. }
  91. if (token.tokenType === "comment") {
  92. // We've already dealt with the previous token's location.
  93. prevToken = undefined;
  94. } else {
  95. let startLoc = {
  96. line: lineNumber,
  97. column: columnNumber
  98. };
  99. token.loc = {start: startLoc};
  100. result.push(token);
  101. prevToken = token;
  102. }
  103. }
  104. return result;
  105. }
  106. /**
  107. * Escape a comment body. Find the comment start and end strings in a
  108. * string and inserts backslashes so that the resulting text can
  109. * itself be put inside a comment.
  110. *
  111. * @param {String} inputString
  112. * input string
  113. * @return {String} the escaped result
  114. */
  115. function escapeCSSComment(inputString) {
  116. let result = inputString.replace(/\/(\\*)\*/g, "/\\$1*");
  117. return result.replace(/\*(\\*)\//g, "*\\$1/");
  118. }
  119. /**
  120. * Un-escape a comment body. This undoes any comment escaping that
  121. * was done by escapeCSSComment. That is, given input like "/\*
  122. * comment *\/", it will strip the backslashes.
  123. *
  124. * @param {String} inputString
  125. * input string
  126. * @return {String} the un-escaped result
  127. */
  128. function unescapeCSSComment(inputString) {
  129. let result = inputString.replace(/\/\\(\\*)\*/g, "/$1*");
  130. return result.replace(/\*\\(\\*)\//g, "*$1/");
  131. }
  132. /**
  133. * A helper function for @see parseDeclarations that handles parsing
  134. * of comment text. This wraps a recursive call to parseDeclarations
  135. * with the processing needed to ensure that offsets in the result
  136. * refer back to the original, unescaped, input string.
  137. *
  138. * @param {Function} isCssPropertyKnown
  139. * A function to check if the CSS property is known. This is either an
  140. * internal server function or from the CssPropertiesFront.
  141. * @param {String} commentText The text of the comment, without the
  142. * delimiters.
  143. * @param {Number} startOffset The offset of the comment opener
  144. * in the original text.
  145. * @param {Number} endOffset The offset of the comment closer
  146. * in the original text.
  147. * @return {array} Array of declarations of the same form as returned
  148. * by parseDeclarations.
  149. */
  150. function parseCommentDeclarations(isCssPropertyKnown, commentText, startOffset,
  151. endOffset) {
  152. let commentOverride = false;
  153. if (commentText === "") {
  154. return [];
  155. } else if (commentText[0] === COMMENT_PARSING_HEURISTIC_BYPASS_CHAR) {
  156. // This is the special sign that the comment was written by
  157. // rewriteDeclarations and so we should bypass the usual
  158. // heuristic.
  159. commentOverride = true;
  160. commentText = commentText.substring(1);
  161. }
  162. let rewrittenText = unescapeCSSComment(commentText);
  163. // We might have rewritten an embedded comment. For example
  164. // /\* ... *\/ would turn into /* ... */.
  165. // This rewriting is necessary for proper lexing, but it means
  166. // that the offsets we get back can be off. So now we compute
  167. // a map so that we can rewrite offsets later. The map is the same
  168. // length as |rewrittenText| and tells us how to map an index
  169. // into |rewrittenText| to an index into |commentText|.
  170. //
  171. // First, we find the location of each comment starter or closer in
  172. // |rewrittenText|. At these spots we put a 1 into |rewrites|.
  173. // Then we walk the array again, using the elements to compute a
  174. // delta, which we use to make the final mapping.
  175. //
  176. // Note we allocate one extra entry because we can see an ending
  177. // offset that is equal to the length.
  178. let rewrites = new Array(rewrittenText.length + 1).fill(0);
  179. let commentRe = /\/\\*\*|\*\\*\//g;
  180. while (true) {
  181. let matchData = commentRe.exec(rewrittenText);
  182. if (!matchData) {
  183. break;
  184. }
  185. rewrites[matchData.index] = 1;
  186. }
  187. let delta = 0;
  188. for (let i = 0; i <= rewrittenText.length; ++i) {
  189. delta += rewrites[i];
  190. // |startOffset| to add the offset from the comment starter, |+2|
  191. // for the length of the "/*", then |i| and |delta| as described
  192. // above.
  193. rewrites[i] = startOffset + 2 + i + delta;
  194. if (commentOverride) {
  195. ++rewrites[i];
  196. }
  197. }
  198. // Note that we pass "false" for parseComments here. It doesn't
  199. // seem worthwhile to support declarations in comments-in-comments
  200. // here, as there's no way to generate those using the tools, and
  201. // users would be crazy to write such things.
  202. let newDecls = parseDeclarationsInternal(isCssPropertyKnown, rewrittenText,
  203. false, true, commentOverride);
  204. for (let decl of newDecls) {
  205. decl.offsets[0] = rewrites[decl.offsets[0]];
  206. decl.offsets[1] = rewrites[decl.offsets[1]];
  207. decl.colonOffsets[0] = rewrites[decl.colonOffsets[0]];
  208. decl.colonOffsets[1] = rewrites[decl.colonOffsets[1]];
  209. decl.commentOffsets = [startOffset, endOffset];
  210. }
  211. return newDecls;
  212. }
  213. /**
  214. * A helper function for parseDeclarationsInternal that creates a new
  215. * empty declaration.
  216. *
  217. * @return {object} an empty declaration of the form returned by
  218. * parseDeclarations
  219. */
  220. function getEmptyDeclaration() {
  221. return {name: "", value: "", priority: "",
  222. terminator: "",
  223. offsets: [undefined, undefined],
  224. colonOffsets: false};
  225. }
  226. /**
  227. * A helper function that does all the parsing work for
  228. * parseDeclarations. This is separate because it has some arguments
  229. * that don't make sense in isolation.
  230. *
  231. * The return value and arguments are like parseDeclarations, with
  232. * these additional arguments.
  233. *
  234. * @param {Function} isCssPropertyKnown
  235. * Function to check if the CSS property is known.
  236. * @param {Boolean} inComment
  237. * If true, assume that this call is parsing some text
  238. * which came from a comment in another declaration.
  239. * In this case some heuristics are used to avoid parsing
  240. * text which isn't obviously a series of declarations.
  241. * @param {Boolean} commentOverride
  242. * This only makes sense when inComment=true.
  243. * When true, assume that the comment was generated by
  244. * rewriteDeclarations, and skip the usual name-checking
  245. * heuristic.
  246. */
  247. function parseDeclarationsInternal(isCssPropertyKnown, inputString,
  248. parseComments, inComment, commentOverride) {
  249. if (inputString === null || inputString === undefined) {
  250. throw new Error("empty input string");
  251. }
  252. let lexer = getCSSLexer(inputString);
  253. let declarations = [getEmptyDeclaration()];
  254. let lastProp = declarations[0];
  255. let current = "", hasBang = false;
  256. while (true) {
  257. let token = lexer.nextToken();
  258. if (!token) {
  259. break;
  260. }
  261. // Ignore HTML comment tokens (but parse anything they might
  262. // happen to surround).
  263. if (token.tokenType === "htmlcomment") {
  264. continue;
  265. }
  266. // Update the start and end offsets of the declaration, but only
  267. // when we see a significant token.
  268. if (token.tokenType !== "whitespace" && token.tokenType !== "comment") {
  269. if (lastProp.offsets[0] === undefined) {
  270. lastProp.offsets[0] = token.startOffset;
  271. }
  272. lastProp.offsets[1] = token.endOffset;
  273. } else if (lastProp.name && !current && !hasBang &&
  274. !lastProp.priority && lastProp.colonOffsets[1]) {
  275. // Whitespace appearing after the ":" is attributed to it.
  276. lastProp.colonOffsets[1] = token.endOffset;
  277. }
  278. if (token.tokenType === "symbol" && token.text === ":") {
  279. if (!lastProp.name) {
  280. // Set the current declaration name if there's no name yet
  281. lastProp.name = current.trim();
  282. lastProp.colonOffsets = [token.startOffset, token.endOffset];
  283. current = "";
  284. hasBang = false;
  285. // When parsing a comment body, if the left-hand-side is not a
  286. // valid property name, then drop it and stop parsing.
  287. if (inComment && !commentOverride &&
  288. !isCssPropertyKnown(lastProp.name)) {
  289. lastProp.name = null;
  290. break;
  291. }
  292. } else {
  293. // Otherwise, just append ':' to the current value (declaration value
  294. // with colons)
  295. current += ":";
  296. }
  297. } else if (token.tokenType === "symbol" && token.text === ";") {
  298. lastProp.terminator = "";
  299. // When parsing a comment, if the name hasn't been set, then we
  300. // have probably just seen an ordinary semicolon used in text,
  301. // so drop this and stop parsing.
  302. if (inComment && !lastProp.name) {
  303. current = "";
  304. break;
  305. }
  306. lastProp.value = current.trim();
  307. current = "";
  308. hasBang = false;
  309. declarations.push(getEmptyDeclaration());
  310. lastProp = declarations[declarations.length - 1];
  311. } else if (token.tokenType === "ident") {
  312. if (token.text === "important" && hasBang) {
  313. lastProp.priority = "important";
  314. hasBang = false;
  315. } else {
  316. if (hasBang) {
  317. current += "!";
  318. }
  319. // Re-escape the token to avoid dequoting problems.
  320. // See bug 1287620.
  321. current += CSS.escape(token.text);
  322. }
  323. } else if (token.tokenType === "symbol" && token.text === "!") {
  324. hasBang = true;
  325. } else if (token.tokenType === "whitespace") {
  326. if (current !== "") {
  327. current += " ";
  328. }
  329. } else if (token.tokenType === "comment") {
  330. if (parseComments && !lastProp.name && !lastProp.value) {
  331. let commentText = inputString.substring(token.startOffset + 2,
  332. token.endOffset - 2);
  333. let newDecls = parseCommentDeclarations(isCssPropertyKnown, commentText,
  334. token.startOffset,
  335. token.endOffset);
  336. // Insert the new declarations just before the final element.
  337. let lastDecl = declarations.pop();
  338. declarations = [...declarations, ...newDecls, lastDecl];
  339. } else {
  340. current += " ";
  341. }
  342. } else {
  343. current += inputString.substring(token.startOffset, token.endOffset);
  344. }
  345. }
  346. // Handle whatever trailing properties or values might still be there
  347. if (current) {
  348. if (!lastProp.name) {
  349. // Ignore this case in comments.
  350. if (!inComment) {
  351. // Trailing property found, e.g. p1:v1;p2:v2;p3
  352. lastProp.name = current.trim();
  353. }
  354. } else {
  355. // Trailing value found, i.e. value without an ending ;
  356. lastProp.value = current.trim();
  357. let terminator = lexer.performEOFFixup("", true);
  358. lastProp.terminator = terminator + ";";
  359. // If the input was unterminated, attribute the remainder to
  360. // this property. This avoids some bad behavior when rewriting
  361. // an unterminated comment.
  362. if (terminator) {
  363. lastProp.offsets[1] = inputString.length;
  364. }
  365. }
  366. }
  367. // Remove declarations that have neither a name nor a value
  368. declarations = declarations.filter(prop => prop.name || prop.value);
  369. return declarations;
  370. }
  371. /**
  372. * Returns an array of CSS declarations given a string.
  373. * For example, parseDeclarations(isCssPropertyKnown, "width: 1px; height: 1px")
  374. * would return:
  375. * [{name:"width", value: "1px"}, {name: "height", "value": "1px"}]
  376. *
  377. * The input string is assumed to only contain declarations so { and }
  378. * characters will be treated as part of either the property or value,
  379. * depending where it's found.
  380. *
  381. * @param {Function} isCssPropertyKnown
  382. * A function to check if the CSS property is known. This is either an
  383. * internal server function or from the CssPropertiesFront.
  384. * that are supported by the server.
  385. * @param {String} inputString
  386. * An input string of CSS
  387. * @param {Boolean} parseComments
  388. * If true, try to parse the contents of comments as well.
  389. * A comment will only be parsed if it occurs outside of
  390. * the body of some other declaration.
  391. * @return {Array} an array of objects with the following signature:
  392. * [{"name": string, "value": string, "priority": string,
  393. * "terminator": string,
  394. * "offsets": [start, end], "colonOffsets": [start, end]},
  395. * ...]
  396. * Here, "offsets" holds the offsets of the start and end
  397. * of the declaration text, in a form suitable for use with
  398. * String.substring.
  399. * "terminator" is a string to use to terminate the declaration,
  400. * usually "" to mean no additional termination is needed.
  401. * "colonOffsets" holds the start and end locations of the
  402. * ":" that separates the property name from the value.
  403. * If the declaration appears in a comment, then there will
  404. * be an additional {"commentOffsets": [start, end] property
  405. * on the object, which will hold the offsets of the start
  406. * and end of the enclosing comment.
  407. */
  408. function parseDeclarations(isCssPropertyKnown, inputString,
  409. parseComments = false) {
  410. return parseDeclarationsInternal(isCssPropertyKnown, inputString,
  411. parseComments, false, false);
  412. }
  413. /**
  414. * Return an object that can be used to rewrite declarations in some
  415. * source text. The source text and parsing are handled in the same
  416. * way as @see parseDeclarations, with |parseComments| being true.
  417. * Rewriting is done by calling one of the modification functions like
  418. * setPropertyEnabled. The returned object has the same interface
  419. * as @see RuleModificationList.
  420. *
  421. * An example showing how to disable the 3rd property in a rule:
  422. *
  423. * let rewriter = new RuleRewriter(isCssPropertyKnown, ruleActor,
  424. * ruleActor.authoredText);
  425. * rewriter.setPropertyEnabled(3, "color", false);
  426. * rewriter.apply().then(() => { ... the change is made ... });
  427. *
  428. * The exported rewriting methods are |renameProperty|, |setPropertyEnabled|,
  429. * |createProperty|, |setProperty|, and |removeProperty|. The |apply|
  430. * method can be used to send the edited text to the StyleRuleActor;
  431. * |getDefaultIndentation| is useful for the methods requiring a
  432. * default indentation value; and |getResult| is useful for testing.
  433. *
  434. * Additionally, editing will set the |changedDeclarations| property
  435. * on this object. This property has the same form as the |changed|
  436. * property of the object returned by |getResult|.
  437. *
  438. * @param {Function} isCssPropertyKnown
  439. * A function to check if the CSS property is known. This is either an
  440. * internal server function or from the CssPropertiesFront.
  441. * that are supported by the server. Note that if Bug 1222047
  442. * is completed then isCssPropertyKnown will not need to be passed in.
  443. * The CssProperty front will be able to obtained directly from the
  444. * RuleRewriter.
  445. * @param {StyleRuleFront} rule The style rule to use. Note that this
  446. * is only needed by the |apply| and |getDefaultIndentation| methods;
  447. * and in particular for testing it can be |null|.
  448. * @param {String} inputString The CSS source text to parse and modify.
  449. * @return {Object} an object that can be used to rewrite the input text.
  450. */
  451. function RuleRewriter(isCssPropertyKnown, rule, inputString) {
  452. this.rule = rule;
  453. this.isCssPropertyKnown = isCssPropertyKnown;
  454. // Keep track of which any declarations we had to rewrite while
  455. // performing the requested action.
  456. this.changedDeclarations = {};
  457. // If not null, a promise that must be wait upon before |apply| can
  458. // do its work.
  459. this.editPromise = null;
  460. // If the |defaultIndentation| property is set, then it is used;
  461. // otherwise the RuleRewriter will try to compute the default
  462. // indentation based on the style sheet's text. This override
  463. // facility is for testing.
  464. this.defaultIndentation = null;
  465. this.startInitialization(inputString);
  466. }
  467. RuleRewriter.prototype = {
  468. /**
  469. * An internal function to initialize the rewriter with a given
  470. * input string.
  471. *
  472. * @param {String} inputString the input to use
  473. */
  474. startInitialization: function (inputString) {
  475. this.inputString = inputString;
  476. // Whether there are any newlines in the input text.
  477. this.hasNewLine = /[\r\n]/.test(this.inputString);
  478. // The declarations.
  479. this.declarations = parseDeclarations(this.isCssPropertyKnown, this.inputString,
  480. true);
  481. this.decl = null;
  482. this.result = null;
  483. },
  484. /**
  485. * An internal function to complete initialization and set some
  486. * properties for further processing.
  487. *
  488. * @param {Number} index The index of the property to modify
  489. */
  490. completeInitialization: function (index) {
  491. if (index < 0) {
  492. throw new Error("Invalid index " + index + ". Expected positive integer");
  493. }
  494. // |decl| is the declaration to be rewritten, or null if there is no
  495. // declaration corresponding to |index|.
  496. // |result| is used to accumulate the result text.
  497. if (index < this.declarations.length) {
  498. this.decl = this.declarations[index];
  499. this.result = this.inputString.substring(0, this.decl.offsets[0]);
  500. } else {
  501. this.decl = null;
  502. this.result = this.inputString;
  503. }
  504. },
  505. /**
  506. * A helper function to compute the indentation of some text. This
  507. * examines the rule's existing text to guess the indentation to use;
  508. * unlike |getDefaultIndentation|, which examines the entire style
  509. * sheet.
  510. *
  511. * @param {String} string the input text
  512. * @param {Number} offset the offset at which to compute the indentation
  513. * @return {String} the indentation at the indicated position
  514. */
  515. getIndentation: function (string, offset) {
  516. let originalOffset = offset;
  517. for (--offset; offset >= 0; --offset) {
  518. let c = string[offset];
  519. if (c === "\r" || c === "\n" || c === "\f") {
  520. return string.substring(offset + 1, originalOffset);
  521. }
  522. if (c !== " " && c !== "\t") {
  523. // Found some non-whitespace character before we found a newline
  524. // -- let's reset the starting point and keep going, as we saw
  525. // something on the line before the declaration.
  526. originalOffset = offset;
  527. }
  528. }
  529. // Ran off the end.
  530. return "";
  531. },
  532. /**
  533. * Modify a property value to ensure it is "lexically safe" for
  534. * insertion into a style sheet. This function doesn't attempt to
  535. * ensure that the resulting text is a valid value for the given
  536. * property; but rather just that inserting the text into the style
  537. * sheet will not cause unwanted changes to other rules or
  538. * declarations.
  539. *
  540. * @param {String} text The input text. This should include the trailing ";".
  541. * @return {Array} An array of the form [anySanitized, text], where
  542. * |anySanitized| is a boolean that indicates
  543. * whether anything substantive has changed; and
  544. * where |text| is the text that has been rewritten
  545. * to be "lexically safe".
  546. */
  547. sanitizePropertyValue: function (text) {
  548. let lexer = getCSSLexer(text);
  549. let result = "";
  550. let previousOffset = 0;
  551. let braceDepth = 0;
  552. let anySanitized = false;
  553. while (true) {
  554. let token = lexer.nextToken();
  555. if (!token) {
  556. break;
  557. }
  558. if (token.tokenType === "symbol") {
  559. switch (token.text) {
  560. case ";":
  561. // We simply drop the ";" here. This lets us cope with
  562. // declarations that don't have a ";" and also other
  563. // termination. The caller handles adding the ";" again.
  564. result += text.substring(previousOffset, token.startOffset);
  565. previousOffset = token.endOffset;
  566. break;
  567. case "{":
  568. ++braceDepth;
  569. break;
  570. case "}":
  571. --braceDepth;
  572. if (braceDepth < 0) {
  573. // Found an unmatched close bracket.
  574. braceDepth = 0;
  575. // Copy out text from |previousOffset|.
  576. result += text.substring(previousOffset, token.startOffset);
  577. // Quote the offending symbol.
  578. result += "\\" + token.text;
  579. previousOffset = token.endOffset;
  580. anySanitized = true;
  581. }
  582. break;
  583. }
  584. }
  585. }
  586. // Copy out any remaining text, then any needed terminators.
  587. result += text.substring(previousOffset, text.length);
  588. let eofFixup = lexer.performEOFFixup("", true);
  589. if (eofFixup) {
  590. anySanitized = true;
  591. result += eofFixup;
  592. }
  593. return [anySanitized, result];
  594. },
  595. /**
  596. * Start at |index| and skip whitespace
  597. * backward in |string|. Return the index of the first
  598. * non-whitespace character, or -1 if the entire string was
  599. * whitespace.
  600. * @param {String} string the input string
  601. * @param {Number} index the index at which to start
  602. * @return {Number} index of the first non-whitespace character, or -1
  603. */
  604. skipWhitespaceBackward: function (string, index) {
  605. for (--index;
  606. index >= 0 && (string[index] === " " || string[index] === "\t");
  607. --index) {
  608. // Nothing.
  609. }
  610. return index;
  611. },
  612. /**
  613. * Terminate a given declaration, if needed.
  614. *
  615. * @param {Number} index The index of the rule to possibly
  616. * terminate. It might be invalid, so this
  617. * function must check for that.
  618. */
  619. maybeTerminateDecl: function (index) {
  620. if (index < 0 || index >= this.declarations.length
  621. // No need to rewrite declarations in comments.
  622. || ("commentOffsets" in this.declarations[index])) {
  623. return;
  624. }
  625. let termDecl = this.declarations[index];
  626. let endIndex = termDecl.offsets[1];
  627. // Due to an oddity of the lexer, we might have gotten a bit of
  628. // extra whitespace in a trailing bad_url token -- so be sure to
  629. // skip that as well.
  630. endIndex = this.skipWhitespaceBackward(this.result, endIndex) + 1;
  631. let trailingText = this.result.substring(endIndex);
  632. if (termDecl.terminator) {
  633. // Insert the terminator just at the end of the declaration,
  634. // before any trailing whitespace.
  635. this.result = this.result.substring(0, endIndex) + termDecl.terminator +
  636. trailingText;
  637. // In a couple of cases, we may have had to add something to
  638. // terminate the declaration, but the termination did not
  639. // actually affect the property's value -- and at this spot, we
  640. // only care about reporting value changes. In particular, we
  641. // might have added a plain ";", or we might have terminated a
  642. // comment with "*/;". Neither of these affect the value.
  643. if (termDecl.terminator !== ";" && termDecl.terminator !== "*/;") {
  644. this.changedDeclarations[index] =
  645. termDecl.value + termDecl.terminator.slice(0, -1);
  646. }
  647. }
  648. // If the rule generally has newlines, but this particular
  649. // declaration doesn't have a trailing newline, insert one now.
  650. // Maybe this style is too weird to bother with.
  651. if (this.hasNewLine && !NEWLINE_RX.test(trailingText)) {
  652. this.result += "\n";
  653. }
  654. },
  655. /**
  656. * Sanitize the given property value and return the sanitized form.
  657. * If the property is rewritten during sanitization, make a note in
  658. * |changedDeclarations|.
  659. *
  660. * @param {String} text The property text.
  661. * @param {Number} index The index of the property.
  662. * @return {String} The sanitized text.
  663. */
  664. sanitizeText: function (text, index) {
  665. let [anySanitized, sanitizedText] = this.sanitizePropertyValue(text);
  666. if (anySanitized) {
  667. this.changedDeclarations[index] = sanitizedText;
  668. }
  669. return sanitizedText;
  670. },
  671. /**
  672. * Rename a declaration.
  673. *
  674. * @param {Number} index index of the property in the rule.
  675. * @param {String} name current name of the property
  676. * @param {String} newName new name of the property
  677. */
  678. renameProperty: function (index, name, newName) {
  679. this.completeInitialization(index);
  680. this.result += CSS.escape(newName);
  681. // We could conceivably compute the name offsets instead so we
  682. // could preserve white space and comments on the LHS of the ":".
  683. this.completeCopying(this.decl.colonOffsets[0]);
  684. },
  685. /**
  686. * Enable or disable a declaration
  687. *
  688. * @param {Number} index index of the property in the rule.
  689. * @param {String} name current name of the property
  690. * @param {Boolean} isEnabled true if the property should be enabled;
  691. * false if it should be disabled
  692. */
  693. setPropertyEnabled: function (index, name, isEnabled) {
  694. this.completeInitialization(index);
  695. const decl = this.decl;
  696. let copyOffset = decl.offsets[1];
  697. if (isEnabled) {
  698. // Enable it. First see if the comment start can be deleted.
  699. let commentStart = decl.commentOffsets[0];
  700. if (EMPTY_COMMENT_START_RX.test(this.result.substring(commentStart))) {
  701. this.result = this.result.substring(0, commentStart);
  702. } else {
  703. this.result += "*/ ";
  704. }
  705. // Insert the name and value separately, so we can report
  706. // sanitization changes properly.
  707. let commentNamePart =
  708. this.inputString.substring(decl.offsets[0],
  709. decl.colonOffsets[1]);
  710. this.result += unescapeCSSComment(commentNamePart);
  711. // When uncommenting, we must be sure to sanitize the text, to
  712. // avoid things like /* decl: }; */, which will be accepted as
  713. // a property but which would break the entire style sheet.
  714. let newText = this.inputString.substring(decl.colonOffsets[1],
  715. decl.offsets[1]);
  716. newText = unescapeCSSComment(newText).trimRight();
  717. this.result += this.sanitizeText(newText, index) + ";";
  718. // See if the comment end can be deleted.
  719. let trailingText = this.inputString.substring(decl.offsets[1]);
  720. if (EMPTY_COMMENT_END_RX.test(trailingText)) {
  721. copyOffset = decl.commentOffsets[1];
  722. } else {
  723. this.result += " /*";
  724. }
  725. } else {
  726. // Disable it. Note that we use our special comment syntax
  727. // here.
  728. let declText = this.inputString.substring(decl.offsets[0],
  729. decl.offsets[1]);
  730. this.result += "/*" + COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
  731. " " + escapeCSSComment(declText) + " */";
  732. }
  733. this.completeCopying(copyOffset);
  734. },
  735. /**
  736. * Return a promise that will be resolved to the default indentation
  737. * of the rule. This is a helper for internalCreateProperty.
  738. *
  739. * @return {Promise} a promise that will be resolved to a string
  740. * that holds the default indentation that should be used
  741. * for edits to the rule.
  742. */
  743. getDefaultIndentation: function () {
  744. return this.rule.parentStyleSheet.guessIndentation();
  745. },
  746. /**
  747. * An internal function to create a new declaration. This does all
  748. * the work of |createProperty|.
  749. *
  750. * @param {Number} index index of the property in the rule.
  751. * @param {String} name name of the new property
  752. * @param {String} value value of the new property
  753. * @param {String} priority priority of the new property; either
  754. * the empty string or "important"
  755. * @param {Boolean} enabled True if the new property should be
  756. * enabled, false if disabled
  757. * @return {Promise} a promise that is resolved when the edit has
  758. * completed
  759. */
  760. internalCreateProperty: Task.async(function* (index, name, value, priority, enabled) {
  761. this.completeInitialization(index);
  762. let newIndentation = "";
  763. if (this.hasNewLine) {
  764. if (this.declarations.length > 0) {
  765. newIndentation = this.getIndentation(this.inputString,
  766. this.declarations[0].offsets[0]);
  767. } else if (this.defaultIndentation) {
  768. newIndentation = this.defaultIndentation;
  769. } else {
  770. newIndentation = yield this.getDefaultIndentation();
  771. }
  772. }
  773. this.maybeTerminateDecl(index - 1);
  774. // If we generally have newlines, and if skipping whitespace
  775. // backward stops at a newline, then insert our text before that
  776. // whitespace. This ensures the indentation we computed is what
  777. // is actually used.
  778. let savedWhitespace = "";
  779. if (this.hasNewLine) {
  780. let wsOffset = this.skipWhitespaceBackward(this.result,
  781. this.result.length);
  782. if (this.result[wsOffset] === "\r" || this.result[wsOffset] === "\n") {
  783. savedWhitespace = this.result.substring(wsOffset + 1);
  784. this.result = this.result.substring(0, wsOffset + 1);
  785. }
  786. }
  787. let newText = CSS.escape(name) + ": " + this.sanitizeText(value, index);
  788. if (priority === "important") {
  789. newText += " !important";
  790. }
  791. newText += ";";
  792. if (!enabled) {
  793. newText = "/*" + COMMENT_PARSING_HEURISTIC_BYPASS_CHAR + " " +
  794. escapeCSSComment(newText) + " */";
  795. }
  796. this.result += newIndentation + newText;
  797. if (this.hasNewLine) {
  798. this.result += "\n";
  799. }
  800. this.result += savedWhitespace;
  801. if (this.decl) {
  802. // Still want to copy in the declaration previously at this
  803. // index.
  804. this.completeCopying(this.decl.offsets[0]);
  805. }
  806. }),
  807. /**
  808. * Create a new declaration.
  809. *
  810. * @param {Number} index index of the property in the rule.
  811. * @param {String} name name of the new property
  812. * @param {String} value value of the new property
  813. * @param {String} priority priority of the new property; either
  814. * the empty string or "important"
  815. * @param {Boolean} enabled True if the new property should be
  816. * enabled, false if disabled
  817. */
  818. createProperty: function (index, name, value, priority, enabled) {
  819. this.editPromise = this.internalCreateProperty(index, name, value,
  820. priority, enabled);
  821. },
  822. /**
  823. * Set a declaration's value.
  824. *
  825. * @param {Number} index index of the property in the rule.
  826. * This can be -1 in the case where
  827. * the rule does not support setRuleText;
  828. * generally for setting properties
  829. * on an element's style.
  830. * @param {String} name the property's name
  831. * @param {String} value the property's value
  832. * @param {String} priority the property's priority, either the empty
  833. * string or "important"
  834. */
  835. setProperty: function (index, name, value, priority) {
  836. this.completeInitialization(index);
  837. // We might see a "set" on a previously non-existent property; in
  838. // that case, act like "create".
  839. if (!this.decl) {
  840. this.createProperty(index, name, value, priority, true);
  841. return;
  842. }
  843. // Note that this assumes that "set" never operates on disabled
  844. // properties.
  845. this.result += this.inputString.substring(this.decl.offsets[0],
  846. this.decl.colonOffsets[1]) +
  847. this.sanitizeText(value, index);
  848. if (priority === "important") {
  849. this.result += " !important";
  850. }
  851. this.result += ";";
  852. this.completeCopying(this.decl.offsets[1]);
  853. },
  854. /**
  855. * Remove a declaration.
  856. *
  857. * @param {Number} index index of the property in the rule.
  858. * @param {String} name the name of the property to remove
  859. */
  860. removeProperty: function (index, name) {
  861. this.completeInitialization(index);
  862. // If asked to remove a property that does not exist, bail out.
  863. if (!this.decl) {
  864. return;
  865. }
  866. // If the property is disabled, then first enable it, and then
  867. // delete it. We take this approach because we want to remove the
  868. // entire comment if possible; but the logic for dealing with
  869. // comments is hairy and already implemented in
  870. // setPropertyEnabled.
  871. if (this.decl.commentOffsets) {
  872. this.setPropertyEnabled(index, name, true);
  873. this.startInitialization(this.result);
  874. this.completeInitialization(index);
  875. }
  876. let copyOffset = this.decl.offsets[1];
  877. // Maybe removing this rule left us with a completely blank
  878. // line. In this case, we'll delete the whole thing. We only
  879. // bother with this if we're looking at sources that already
  880. // have a newline somewhere.
  881. if (this.hasNewLine) {
  882. let nlOffset = this.skipWhitespaceBackward(this.result,
  883. this.decl.offsets[0]);
  884. if (nlOffset < 0 || this.result[nlOffset] === "\r" ||
  885. this.result[nlOffset] === "\n") {
  886. let trailingText = this.inputString.substring(copyOffset);
  887. let match = BLANK_LINE_RX.exec(trailingText);
  888. if (match) {
  889. this.result = this.result.substring(0, nlOffset + 1);
  890. copyOffset += match[0].length;
  891. }
  892. }
  893. }
  894. this.completeCopying(copyOffset);
  895. },
  896. /**
  897. * An internal function to copy any trailing text to the output
  898. * string.
  899. *
  900. * @param {Number} copyOffset Offset into |inputString| of the
  901. * final text to copy to the output string.
  902. */
  903. completeCopying: function (copyOffset) {
  904. // Add the trailing text.
  905. this.result += this.inputString.substring(copyOffset);
  906. },
  907. /**
  908. * Apply the modifications in this object to the associated rule.
  909. *
  910. * @return {Promise} A promise which will be resolved when the modifications
  911. * are complete.
  912. */
  913. apply: function () {
  914. return promise.resolve(this.editPromise).then(() => {
  915. return this.rule.setRuleText(this.result);
  916. });
  917. },
  918. /**
  919. * Get the result of the rewriting. This is used for testing.
  920. *
  921. * @return {object} an object of the form {changed: object, text: string}
  922. * |changed| is an object where each key is
  923. * the index of a property whose value had to be
  924. * rewritten during the sanitization process, and
  925. * whose value is the new text of the property.
  926. * |text| is the rewritten text of the rule.
  927. */
  928. getResult: function () {
  929. return {changed: this.changedDeclarations, text: this.result};
  930. },
  931. };
  932. /**
  933. * Returns an array of the parsed CSS selector value and type given a string.
  934. *
  935. * The components making up the CSS selector can be extracted into 3 different
  936. * types: element, attribute and pseudoclass. The object that is appended to
  937. * the returned array contains the value related to one of the 3 types described
  938. * along with the actual type.
  939. *
  940. * The following are the 3 types that can be returned in the object signature:
  941. * (1) SELECTOR_ATTRIBUTE
  942. * (2) SELECTOR_ELEMENT
  943. * (3) SELECTOR_PSEUDO_CLASS
  944. *
  945. * @param {String} value
  946. * The CSS selector text.
  947. * @return {Array} an array of objects with the following signature:
  948. * [{ "value": string, "type": integer }, ...]
  949. */
  950. function parsePseudoClassesAndAttributes(value) {
  951. if (!value) {
  952. throw new Error("empty input string");
  953. }
  954. let tokens = cssTokenizer(value);
  955. let result = [];
  956. let current = "";
  957. let functionCount = 0;
  958. let hasAttribute = false;
  959. let hasColon = false;
  960. for (let token of tokens) {
  961. if (token.tokenType === "ident") {
  962. current += value.substring(token.startOffset, token.endOffset);
  963. if (hasColon && !functionCount) {
  964. if (current) {
  965. result.push({ value: current, type: SELECTOR_PSEUDO_CLASS });
  966. }
  967. current = "";
  968. hasColon = false;
  969. }
  970. } else if (token.tokenType === "symbol" && token.text === ":") {
  971. if (!hasColon) {
  972. if (current) {
  973. result.push({ value: current, type: SELECTOR_ELEMENT });
  974. }
  975. current = "";
  976. hasColon = true;
  977. }
  978. current += token.text;
  979. } else if (token.tokenType === "function") {
  980. current += value.substring(token.startOffset, token.endOffset);
  981. functionCount++;
  982. } else if (token.tokenType === "symbol" && token.text === ")") {
  983. current += token.text;
  984. if (hasColon && functionCount == 1) {
  985. if (current) {
  986. result.push({ value: current, type: SELECTOR_PSEUDO_CLASS });
  987. }
  988. current = "";
  989. functionCount--;
  990. hasColon = false;
  991. } else {
  992. functionCount--;
  993. }
  994. } else if (token.tokenType === "symbol" && token.text === "[") {
  995. if (!hasAttribute && !functionCount) {
  996. if (current) {
  997. result.push({ value: current, type: SELECTOR_ELEMENT });
  998. }
  999. current = "";
  1000. hasAttribute = true;
  1001. }
  1002. current += token.text;
  1003. } else if (token.tokenType === "symbol" && token.text === "]") {
  1004. current += token.text;
  1005. if (hasAttribute && !functionCount) {
  1006. if (current) {
  1007. result.push({ value: current, type: SELECTOR_ATTRIBUTE });
  1008. }
  1009. current = "";
  1010. hasAttribute = false;
  1011. }
  1012. } else {
  1013. current += value.substring(token.startOffset, token.endOffset);
  1014. }
  1015. }
  1016. if (current) {
  1017. result.push({ value: current, type: SELECTOR_ELEMENT });
  1018. }
  1019. return result;
  1020. }
  1021. /**
  1022. * Expects a single CSS value to be passed as the input and parses the value
  1023. * and priority.
  1024. *
  1025. * @param {Function} isCssPropertyKnown
  1026. * A function to check if the CSS property is known. This is either an
  1027. * internal server function or from the CssPropertiesFront.
  1028. * that are supported by the server.
  1029. * @param {String} value
  1030. * The value from the text editor.
  1031. * @return {Object} an object with 'value' and 'priority' properties.
  1032. */
  1033. function parseSingleValue(isCssPropertyKnown, value) {
  1034. let declaration = parseDeclarations(isCssPropertyKnown,
  1035. "a: " + value + ";")[0];
  1036. return {
  1037. value: declaration ? declaration.value : "",
  1038. priority: declaration ? declaration.priority : ""
  1039. };
  1040. }
  1041. /**
  1042. * Convert an angle value to degree.
  1043. *
  1044. * @param {Number} angleValue The angle value.
  1045. * @param {CSS_ANGLEUNIT} angleUnit The angleValue's angle unit.
  1046. * @return {Number} An angle value in degree.
  1047. */
  1048. function getAngleValueInDegrees(angleValue, angleUnit) {
  1049. switch (angleUnit) {
  1050. case CSS_ANGLEUNIT.deg:
  1051. return angleValue;
  1052. case CSS_ANGLEUNIT.grad:
  1053. return angleValue * 0.9;
  1054. case CSS_ANGLEUNIT.rad:
  1055. return angleValue * 180 / Math.PI;
  1056. case CSS_ANGLEUNIT.turn:
  1057. return angleValue * 360;
  1058. default:
  1059. throw new Error("No matched angle unit.");
  1060. }
  1061. }
  1062. exports.cssTokenizer = cssTokenizer;
  1063. exports.cssTokenizerWithLineColumn = cssTokenizerWithLineColumn;
  1064. exports.escapeCSSComment = escapeCSSComment;
  1065. // unescapeCSSComment is exported for testing.
  1066. exports._unescapeCSSComment = unescapeCSSComment;
  1067. exports.parseDeclarations = parseDeclarations;
  1068. // parseCommentDeclarations is exported for testing.
  1069. exports._parseCommentDeclarations = parseCommentDeclarations;
  1070. exports.RuleRewriter = RuleRewriter;
  1071. exports.parsePseudoClassesAndAttributes = parsePseudoClassesAndAttributes;
  1072. exports.parseSingleValue = parseSingleValue;
  1073. exports.getAngleValueInDegrees = getAngleValueInDegrees;