Bug 1145527 - Display the inherited and used CSS Variable in the rule view. r=tromey draft
authorGabriel Luong <gabriel.luong@gmail.com>
Wed, 30 Aug 2017 01:45:22 -0400
changeset 655554 50c2f2768efcdf3874a3a61effa98a37e9e1e178
parent 655553 b85516a5f3bd3712b82292084730576704f93336
child 728869 535ee5671ccd62d1c1d7282f0dc849fd0debd09f
push id76912
push userbmo:gl@mozilla.com
push dateWed, 30 Aug 2017 05:48:40 +0000
reviewerstromey
bugs1145527
milestone57.0a1
Bug 1145527 - Display the inherited and used CSS Variable in the rule view. r=tromey - Displays which CSS variable is used in the rule view. - Displays the actual variable value on the variable name's title property. - Displays all the inherited CSS variables on the selected element in the rule view. MozReview-Commit-ID: J9KEB0RRAHl
devtools/client/inspector/rules/models/element-style.js
devtools/client/inspector/rules/test/browser.ini
devtools/client/inspector/rules/test/browser_rules_variables_01.js
devtools/client/inspector/rules/test/browser_rules_variables_02.js
devtools/client/inspector/rules/test/doc_variables_1.html
devtools/client/inspector/rules/test/doc_variables_2.html
devtools/client/inspector/rules/views/text-property-editor.js
devtools/client/shared/output-parser.js
devtools/client/shared/test/browser_outputparser.js
devtools/client/themes/rules.css
devtools/shared/fronts/css-properties.js
devtools/shared/locales/en-US/styleinspector.properties
--- a/devtools/client/inspector/rules/models/element-style.js
+++ b/devtools/client/inspector/rules/models/element-style.js
@@ -5,17 +5,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const promise = require("promise");
 const Rule = require("devtools/client/inspector/rules/models/rule");
 const {promiseWarn} = require("devtools/client/inspector/shared/utils");
 const {ELEMENT_STYLE} = require("devtools/shared/specs/styles");
-const {getCssProperties} = require("devtools/shared/fronts/css-properties");
+const {getCssProperties, isCssVariable} = require("devtools/shared/fronts/css-properties");
 
 /**
  * ElementStyle is responsible for the following:
  *   Keeps track of which properties are overridden.
  *   Maintains a list of Rule objects for a given element.
  *
  * @param {Element} element
  *        The element whose style we are viewing.
@@ -35,16 +35,17 @@ function ElementStyle(element, ruleView,
     showUserAgentStyles) {
   this.element = element;
   this.ruleView = ruleView;
   this.store = store || {};
   this.pageStyle = pageStyle;
   this.showUserAgentStyles = showUserAgentStyles;
   this.rules = [];
   this.cssProperties = getCssProperties(this.ruleView.inspector.toolbox);
+  this.variables = new Map();
 
   // We don't want to overwrite this.store.userProperties so we only create it
   // if it doesn't already exist.
   if (!("userProperties" in this.store)) {
     this.store.userProperties = new UserProperties();
   }
 
   if (!("disabled" in this.store)) {
@@ -194,17 +195,19 @@ ElementStyle.prototype = {
     this.rules.push(rule);
     return true;
   },
 
   /**
    * Calls markOverridden with all supported pseudo elements
    */
   markOverriddenAll: function () {
+    this.variables.clear();
     this.markOverridden();
+
     for (let pseudo of this.cssProperties.pseudoElements) {
       this.markOverridden(pseudo);
     }
   },
 
   /**
    * Mark the properties listed in this.rules for a given pseudo element
    * with an overridden flag if an earlier property overrides it.
@@ -283,16 +286,20 @@ ElementStyle.prototype = {
         overridden = !!earlier;
       }
 
       computedProp._overriddenDirty =
         (!!computedProp.overridden !== overridden);
       computedProp.overridden = overridden;
       if (!computedProp.overridden && computedProp.textProp.enabled) {
         taken[computedProp.name] = computedProp;
+
+        if (isCssVariable(computedProp.name)) {
+          this.variables.set(computedProp.name, computedProp.value);
+        }
       }
     }
 
     // For each TextProperty, mark it overridden if all of its
     // computed properties are marked overridden.  Update the text
     // property's associated editor, if any.  This will clear the
     // _overriddenDirty state on all computed properties.
     for (let textProp of textProps) {
@@ -323,17 +330,30 @@ ElementStyle.prototype = {
       }
       dirty = computedProp._overriddenDirty || dirty;
       delete computedProp._overriddenDirty;
     }
 
     dirty = (!!prop.overridden !== overridden) || dirty;
     prop.overridden = overridden;
     return dirty;
-  }
+  },
+
+ /**
+  * Returns the current value of a CSS variable; or null if the
+  * variable is not defined.
+  *
+  * @param  {String} name
+  *         The name of the variable.
+  * @return {String} the variable's value or null if the variable is
+  *         not defined.
+  */
+  getVariable: function (name) {
+    return this.variables.get(name);
+  },
 };
 
 /**
  * Store of CSSStyleDeclarations mapped to properties that have been changed by
  * the user.
  */
 function UserProperties() {
   this.map = new Map();
--- a/devtools/client/inspector/rules/test/browser.ini
+++ b/devtools/client/inspector/rules/test/browser.ini
@@ -31,16 +31,18 @@ support-files =
   doc_sourcemaps.scss
   doc_sourcemaps2.css
   doc_sourcemaps2.css^headers^
   doc_sourcemaps2.html
   doc_style_editor_link.css
   doc_test_image.png
   doc_urls_clickable.css
   doc_urls_clickable.html
+  doc_variables_1.html
+  doc_variables_2.html
   head.js
   !/devtools/client/commandline/test/helpers.js
   !/devtools/client/framework/test/shared-head.js
   !/devtools/client/inspector/test/head.js
   !/devtools/client/inspector/test/shared-head.js
   !/devtools/client/shared/test/test-actor.js
   !/devtools/client/shared/test/test-actor-registry.js
 
@@ -91,16 +93,18 @@ skip-if = stylo # Bug 1387445
 [browser_rules_completion-new-property_03.js]
 [browser_rules_completion-new-property_04.js]
 [browser_rules_completion-new-property_multiline.js]
 [browser_rules_computed-lists_01.js]
 [browser_rules_computed-lists_02.js]
 [browser_rules_completion-popup-hidden-after-navigation.js]
 [browser_rules_content_01.js]
 [browser_rules_content_02.js]
+[browser_rules_variables_01.js]
+[browser_rules_variables_02.js]
 skip-if = e10s && debug # Bug 1250058 - Docshell leak on debug e10s
 [browser_rules_context-menu-show-mdn-docs-01.js]
 [browser_rules_context-menu-show-mdn-docs-02.js]
 [browser_rules_context-menu-show-mdn-docs-03.js]
 [browser_rules_copy_styles.js]
 subsuite = clipboard
 skip-if = (os == 'linux' && bits == 32 && debug) # bug 1328915, disable linux32 debug devtools for timeouts
 [browser_rules_cssom.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_variables_01.js
@@ -0,0 +1,35 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for variables in rule view.
+
+const TEST_URI = URL_ROOT + "doc_variables_1.html";
+
+add_task(function* () {
+  yield addTab(TEST_URI);
+  let {inspector, view} = yield openRuleView();
+  yield selectNode("#target", inspector);
+
+  info("Tests basic support for CSS Variables for both single variable " +
+  "and double variable. Formats tested: var(x, constant), var(x, var(y))");
+
+  let unsetColor = getRuleViewProperty(view, "div", "color").valueSpan
+    .querySelector(".ruleview-variable-unmatched");
+  let setColor = unsetColor.previousElementSibling;
+  is(unsetColor.textContent, " red", "red is unmatched in color");
+  is(setColor.textContent, "--color", "--color is not set correctly");
+  is(setColor.title, "--color = chartreuse", "--color's title is not set correctly");
+
+  let unsetVar = getRuleViewProperty(view, "div", "background-color").valueSpan
+    .querySelector(".ruleview-variable-unmatched");
+  let setVar = unsetVar.nextElementSibling;
+  let setVarName = setVar.firstElementChild.firstElementChild;
+  is(unsetVar.textContent, "--not-set",
+     "--not-set is unmatched in background-color");
+  is(setVar.textContent, " var(--bg)", "var(--bg) parsed incorrectly");
+  is(setVarName.textContent, "--bg", "--bg is not set correctly");
+  is(setVarName.title, "--bg = seagreen", "--bg's title is not set correctly");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_variables_02.js
@@ -0,0 +1,190 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for variables in rule view.
+
+const TEST_URI = URL_ROOT + "doc_variables_2.html";
+
+add_task(function* () {
+  yield addTab(TEST_URI);
+  let {inspector, view} = yield openRuleView();
+
+  yield testBasic(inspector, view);
+  yield testNestedCssFunctions(inspector, view);
+  yield testBorderShorthandAndInheritance(inspector, view);
+  yield testSingleLevelVariable(inspector, view);
+  yield testDoubleLevelVariable(inspector, view);
+  yield testTripleLevelVariable(inspector, view);
+});
+
+function* testBasic(inspector, view) {
+  info("Test support for basic variable functionality for var() with 2 variables." +
+       "Format: var(--var1, var(--var2))");
+
+  yield selectNode("#a", inspector);
+  let unsetVar = getRuleViewProperty(view, "#a", "font-size").valueSpan
+    .querySelector(".ruleview-variable-unmatched");
+  let setVarParent = unsetVar.nextElementSibling;
+  let setVar = getVarFromParent(setVarParent);
+  is(unsetVar.textContent, "--var-not-defined",
+    "--var-not-defined is not set correctly");
+  is(unsetVar.title, "--var-not-defined is not set",
+    "--var-not-defined's title is not set correctly");
+  is(setVarParent.textContent, " var(--var-defined-font-size)",
+    "var(--var-defined-font-size) parsed incorrectly");
+  is(setVar.textContent, "--var-defined-font-size",
+    "--var-defined-font-size is not set correctly");
+  is(setVar.title, "--var-defined-font-size = 60px",
+    "--bg's title is not set correctly");
+}
+
+function* testNestedCssFunctions(inspector, view) {
+  info("Test support for variable functionality for a var() nested inside " +
+  "another CSS function. Format: rgb(0, 0, var(--var1, var(--var2)))");
+
+  yield selectNode("#b", inspector);
+  let unsetVarParent = getRuleViewProperty(view, "#b", "color").valueSpan
+    .querySelector(".ruleview-variable-unmatched");
+  let unsetVar = getVarFromParent(unsetVarParent);
+  let setVar = unsetVarParent.previousElementSibling;
+  is(unsetVarParent.textContent, " var(--var-defined-r-2)",
+    "var(--var-defined-r-2) not parsed correctly");
+  is(unsetVar.textContent, "--var-defined-r-2",
+    "--var-defined-r-2 is not set correctly");
+  is(unsetVar.title, "--var-defined-r-2 = 0",
+    "--var-defined-r-2's title is not set correctly");
+  is(setVar.textContent, "--var-defined-r-1",
+    "--var-defined-r-1 is not set correctly");
+  is(setVar.title, "--var-defined-r-1 = 255",
+    "--var-defined-r-1's title is not set correctly");
+}
+
+function* testBorderShorthandAndInheritance(inspector, view) {
+  info("Test support for variable functionality for shorthands/CSS styles with spaces " +
+  "like \"margin: w x y z\". Also tests functionality for inherticance of CSS" +
+  " variables. Format: var(l, var(m)) var(x) rgb(var(r) var(g) var(b))");
+
+  yield selectNode("#c", inspector);
+  let unsetVarL = getRuleViewProperty(view, "#c", "border").valueSpan
+    .querySelector(".ruleview-variable-unmatched");
+  let setVarMParent = unsetVarL.nextElementSibling;
+
+  // var(x) is the next sibling of the parent of M
+  let setVarXParent = setVarMParent.parentNode.nextElementSibling;
+
+  // var(r) is the next sibling of var(x), and var(g) is the next sibling of var(r), etc.
+  let setVarRParent = setVarXParent.nextElementSibling;
+  let setVarGParent = setVarRParent.nextElementSibling;
+  let setVarBParent = setVarGParent.nextElementSibling;
+
+  let setVarM = getVarFromParent(setVarMParent);
+  let setVarX = setVarXParent.firstElementChild;
+  let setVarR = setVarRParent.firstElementChild;
+  let setVarG = setVarGParent.firstElementChild;
+  let setVarB = setVarBParent.firstElementChild;
+
+  is(unsetVarL.textContent, "--var-undefined",
+    "--var-undefined is not set correctly");
+  is(unsetVarL.title, "--var-undefined is not set",
+    "--var-undefined's title is not set correctly");
+
+  is(setVarM.textContent, "--var-border-px",
+    "--var-border-px is not set correctly");
+  is(setVarM.title, "--var-border-px = 10px",
+    "--var-border-px's title is not set correctly");
+
+  is(setVarX.textContent, "--var-border-style",
+    "--var-border-style is not set correctly");
+  is(setVarX.title, "--var-border-style = solid",
+    "var-border-style's title is not set correctly");
+
+  is(setVarR.textContent, "--var-border-r",
+    "--var-defined-r is not set correctly");
+  is(setVarR.title, "--var-border-r = 255",
+    "--var-defined-r's title is not set correctly");
+
+  is(setVarG.textContent, "--var-border-g",
+    "--var-defined-g is not set correctly");
+  is(setVarG.title, "--var-border-g = 0",
+    "--var-defined-g's title is not set correctly");
+
+  is(setVarB.textContent, "--var-border-b",
+    "--var-defined-b is not set correctly");
+  is(setVarB.title, "--var-border-b = 0",
+    "--var-defined-b's title is not set correctly");
+}
+
+function* testSingleLevelVariable(inspector, view) {
+  info("Test support for variable functionality of a single level of " +
+  "undefined variables. Format: var(x, constant)");
+
+  yield selectNode("#d", inspector);
+  let unsetVar = getRuleViewProperty(view, "#d", "font-size").valueSpan
+    .querySelector(".ruleview-variable-unmatched");
+
+  is(unsetVar.textContent, "--var-undefined",
+    "--var-undefined is not set correctly");
+  is(unsetVar.title, "--var-undefined is not set",
+    "--var-undefined's title is not set correctly");
+}
+
+function* testDoubleLevelVariable(inspector, view) {
+  info("Test support for variable functionality of double level of " +
+  "undefined variables. Format: var(x, var(y, constant))");
+
+  yield selectNode("#e", inspector);
+  let allUnsetVars = getRuleViewProperty(view, "#e", "color").valueSpan
+    .querySelectorAll(".ruleview-variable-unmatched");
+
+  is(allUnsetVars.length, 2, "The number of unset variables is mismatched.");
+
+  let unsetVar1 = allUnsetVars[0];
+  let unsetVar2 = allUnsetVars[1];
+
+  is(unsetVar1.textContent, "--var-undefined",
+    "--var-undefined is not set correctly");
+  is(unsetVar1.title, "--var-undefined is not set",
+    "--var-undefined's title is not set correctly");
+
+  is(unsetVar2.textContent, "--var-undefined-2",
+    "--var-undefined is not set correctly");
+  is(unsetVar2.title, "--var-undefined-2 is not set",
+    "--var-undefined-2's title is not set correctly");
+}
+
+function* testTripleLevelVariable(inspector, view) {
+  info("Test support for variable functionality of triple level of " +
+  "undefined variables. Format: var(x, var(y, var(z, constant)))");
+
+  yield selectNode("#f", inspector);
+  let allUnsetVars = getRuleViewProperty(view, "#f", "border-style").valueSpan
+    .querySelectorAll(".ruleview-variable-unmatched");
+
+  is(allUnsetVars.length, 3, "The number of unset variables is mismatched.");
+
+  let unsetVar1 = allUnsetVars[0];
+  let unsetVar2 = allUnsetVars[1];
+  let unsetVar3 = allUnsetVars[2];
+
+  is(unsetVar1.textContent, "--var-undefined",
+    "--var-undefined is not set correctly");
+  is(unsetVar1.title, "--var-undefined is not set",
+    "--var-undefined's title is not set correctly");
+
+  is(unsetVar2.textContent, "--var-undefined-2",
+    "--var-undefined-2 is not set correctly");
+  is(unsetVar2.title, "--var-undefined-2 is not set",
+    "--var-defined-r-2's title is not set correctly");
+
+  is(unsetVar3.textContent, "--var-undefined-3",
+    "--var-undefined-3 is not set correctly");
+  is(unsetVar3.title, "--var-undefined-3 is not set",
+    "--var-defined-r-3's title is not set correctly");
+}
+
+function getVarFromParent(varParent) {
+  return varParent.firstElementChild.firstElementChild;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/doc_variables_1.html
@@ -0,0 +1,23 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<html>
+<head>
+  <title>variables test</title>
+
+  <style>
+    * {
+      --color: tomato;
+      --bg: violet;
+    }
+
+    div {
+      --color: chartreuse;
+      color: var(--color, red);
+      background-color: var(--not-set, var(--bg));
+    }
+  </style>
+</head>
+<body>
+  <div id="target" style="--bg: seagreen;"> the ocean </div>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/doc_variables_2.html
@@ -0,0 +1,45 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<html>
+<head>
+  <title>variables test</title>
+  <style>
+    :root {
+      --var-border-px: 10px;
+      --var-border-style: solid;
+      --var-border-r: 255;
+      --var-border-g: 0;
+      --var-border-b: 0;
+    }
+    #a {
+      --var-defined-font-size: 60px;
+      font-size: var(--var-not-defined, var(--var-defined-font-size));
+    }
+    #b {
+      --var-defined-r-1: 255;
+      --var-defined-r-2: 0;
+      color: rgb(var(--var-defined-r-1, var(--var-defined-r-2)), 0, 0);
+    }
+    #c {
+      border: var(--var-undefined, var(--var-border-px)) var(--var-border-style) rgb(var(--var-border-r), var(--var-border-g), var(--var-border-b))
+    }
+    #d {
+      font-size: var(--var-undefined, 30px);
+    }
+    #e {
+      color: var(--var-undefined, var(--var-undefined-2, blue));
+    }
+    #f {
+      border-style: var(--var-undefined, var(--var-undefined-2, var(--var-undefined-3, solid)));
+    }
+  </style>
+</head>
+<body>
+  <div id="a">A</div><br>
+  <div id="b">B</div><br>
+  <div id="c">C</div><br>
+  <div id="d">D</div><br>
+  <div id="e">E</div><br>
+  <div id="f">F</div>
+</body>
+</html>
--- a/devtools/client/inspector/rules/views/text-property-editor.js
+++ b/devtools/client/inspector/rules/views/text-property-editor.js
@@ -358,17 +358,19 @@ TextPropertyEditor.prototype = {
       colorClass: "ruleview-color",
       colorSwatchClass: SHARED_SWATCH_CLASS + " " + COLOR_SWATCH_CLASS,
       filterClass: "ruleview-filter",
       filterSwatchClass: SHARED_SWATCH_CLASS + " " + FILTER_SWATCH_CLASS,
       gridClass: "ruleview-grid",
       shapeClass: "ruleview-shape",
       defaultColorType: !propDirty,
       urlClass: "theme-link",
-      baseURI: this.sheetHref
+      baseURI: this.sheetHref,
+      unmatchedVariableClass: "ruleview-variable-unmatched",
+      isVariableInUse: varName => this.rule.elementStyle.getVariable(varName),
     };
     let frag = outputParser.parseCssProperty(name, val, parserOptions);
     this.valueSpan.innerHTML = "";
     this.valueSpan.appendChild(frag);
 
     this.ruleView.emit("property-value-updated", this.valueSpan);
 
     // Attach the color picker tooltip to the color swatches
--- a/devtools/client/shared/output-parser.js
+++ b/devtools/client/shared/output-parser.js
@@ -13,16 +13,20 @@ const {
   BASIC_SHAPE_FUNCTIONS,
   BEZIER_KEYWORDS,
   COLOR_TAKING_FUNCTIONS,
   CSS_TYPES
 } = require("devtools/shared/css/properties-db");
 const {appendText} = require("devtools/client/inspector/shared/utils");
 const Services = require("Services");
 
+const STYLE_INSPECTOR_PROPERTIES = "devtools/shared/locales/styleinspector.properties";
+const {LocalizationHelper} = require("devtools/shared/l10n");
+const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);
+
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled";
 const CSS_SHAPES_ENABLED_PREF = "devtools.inspector.shapesHighlighter.enabled";
 
 /**
  * This module is used to process text for output by developer tools. This means
  * linking JS files with the debugger, CSS files with the style editor, JS
  * functions with the debugger, placing color swatches next to colors and
@@ -91,92 +95,215 @@ OutputParser.prototype = {
       return this._parse(value, options);
     }
     this._appendTextNode(value);
 
     return this._toDOM();
   },
 
   /**
-   * Given an initial FUNCTION token, read tokens from |tokenStream|
-   * and collect all the (non-comment) text.  Return the collected
-   * text.  The function token and the close paren are included in the
-   * result.
+   * Read tokens from |tokenStream| and collect all the (non-comment)
+   * text. Return the collected texts and variable data (if any).
+   * Stop when an unmatched closing paren is seen.
+   * If |stopAtComma| is true, then also stop when a top-level
+   * (unparenthesized) comma is seen.
    *
-   * @param  {CSSToken} initialToken
-   *         The FUNCTION token.
    * @param  {String} text
-   *         The original CSS text.
+   *         The original source text.
    * @param  {CSSLexer} tokenStream
    *         The token stream from which to read.
-   * @return {String}
-   *         The text of body of the function call.
+   * @param  {Object} options
+   *         The options object in use; @see _mergeOptions.
+   * @param  {Boolean} stopAtComma
+   *         If true, stop at a comma.
+   * @return {Object}
+   *         An object of the form {tokens, functionData, sawComma, sawVariable}.
+   *         |tokens| is a list of the non-comment, non-whitespace tokens
+   *         that were seen. The stopping token (paren or comma) will not
+   *         be included.
+   *         |functionData| is a list of parsed strings and nodes that contain the
+   *         data between the matching parenthesis. The stopping token's text will
+   *         not be included.
+   *         |sawComma| is true if the stop was due to a comma, or false otherwise.
+   *         |sawVariable| is true if a variable was seen while parsing the text.
    */
-  _collectFunctionText: function (initialToken, text, tokenStream) {
-    let result = text.substring(initialToken.startOffset,
-                                initialToken.endOffset);
+  _parseMatchingParens: function (text, tokenStream, options, stopAtComma) {
     let depth = 1;
+    let functionData = [];
+    let tokens = [];
+    let sawVariable = false;
+
     while (depth > 0) {
       let token = tokenStream.nextToken();
       if (!token) {
         break;
       }
       if (token.tokenType === "comment") {
         continue;
       }
-      result += text.substring(token.startOffset, token.endOffset);
+
       if (token.tokenType === "symbol") {
-        if (token.text === "(") {
+        if (stopAtComma && depth === 1 && token.text === ",") {
+          return { tokens, functionData, sawComma: true, sawVariable };
+        } else if (token.text === "(") {
           ++depth;
         } else if (token.text === ")") {
           --depth;
+          if (depth === 0) {
+            break;
+          }
         }
+      } else if (token.tokenType === "function" && token.text === "var" &&
+                 options.isVariableInUse) {
+        sawVariable = true;
+        let variableNode = this._parseVariable(token, text, tokenStream, options);
+        functionData.push(variableNode);
       } else if (token.tokenType === "function") {
         ++depth;
       }
+
+      if (token.tokenType !== "function" || token.text !== "var" ||
+          !options.isVariableInUse) {
+        functionData.push(text.substring(token.startOffset, token.endOffset));
+      }
+
+      if (token.tokenType !== "whitespace") {
+        tokens.push(token);
+      }
     }
-    return result;
+
+    return { tokens, functionData, sawComma: false, sawVariable };
   },
 
   /**
-   * Parse a string.
+   * Parse var() use and return a variable node to be added to the output state.
+   * This will read tokens up to and including the ")" that closes the "var("
+   * invocation.
+   *
+   * @param  {CSSToken} initialToken
+   *         The "var(" token that was already seen.
+   * @param  {String} text
+   *         The original input text.
+   * @param  {CSSLexer} tokenStream
+   *         The token stream from which to read.
+   * @param  {Object} options
+   *         The options object in use; @see _mergeOptions.
+   * @return {Object}
+   *         A node for the variable, with the appropriate text and
+   *         title. Eg. a span with "var(--var1)" as the textContent
+   *         and a title for --var1 like "--var1 = 10" or
+   *         "--var1 is not set".
+   */
+  _parseVariable: function (initialToken, text, tokenStream, options) {
+    // Handle the "var(".
+    let varText = text.substring(initialToken.startOffset,
+                                 initialToken.endOffset);
+    let variableNode = this._createNode("span", {}, varText);
+
+    // Parse the first variable name within the parens of var().
+    let {tokens, functionData, sawComma, sawVariable} =
+        this._parseMatchingParens(text, tokenStream, options, true);
+
+    let result = sawVariable ? "" : functionData.join("");
+
+    // Display options for the first and second argument in the var().
+    let firstOpts = {};
+    let secondOpts = {};
+
+    let varValue;
+
+    // Get the variable value if it is in use.
+    if (tokens && tokens.length === 1) {
+      varValue = options.isVariableInUse(tokens[0].text);
+    }
+
+    // Get the variable name.
+    let varName = text.substring(tokens[0].startOffset, tokens[0].endOffset);
+
+    if (typeof varValue === "string") {
+      // The variable value is valid, set the variable name's title of the first argument
+      // in var() to display the variable name and value.
+      firstOpts.title =
+        STYLE_INSPECTOR_L10N.getFormatStr("rule.variableValue", varName, varValue);
+      secondOpts.class = options.unmatchedVariableClass;
+    } else {
+      // The variable name is not valid, mark it unmatched.
+      firstOpts.class = options.unmatchedVariableClass;
+      firstOpts.title = STYLE_INSPECTOR_L10N.getFormatStr("rule.variableUnset",
+                                                          varName);
+    }
+
+    variableNode.appendChild(this._createNode("span", firstOpts, result));
+
+    // If we saw a ",", then append it and show the remainder using
+    // the correct highlighting.
+    if (sawComma) {
+      variableNode.appendChild(this.doc.createTextNode(","));
+
+      // Parse the text up until the close paren, being sure to
+      // disable the special case for filter.
+      let subOptions = Object.assign({}, options);
+      subOptions.expectFilter = false;
+      let saveParsed = this.parsed;
+      this.parsed = [];
+      let rest = this._doParse(text, subOptions, tokenStream, true);
+      this.parsed = saveParsed;
+
+      let span = this._createNode("span", secondOpts);
+      span.appendChild(rest);
+      variableNode.appendChild(span);
+    }
+    variableNode.appendChild(this.doc.createTextNode(")"));
+
+    return variableNode;
+  },
+
+  /* eslint-disable complexity */
+  /**
+   * The workhorse for @see _parse. This parses some CSS text,
+   * stopping at EOF; or optionally when an umatched close paren is
+   * seen.
    *
    * @param  {String} text
-   *         Text to parse.
-   * @param  {Object} [options]
-   *         Options object. For valid options and default values see
-   *         _mergeOptions().
+   *         The original input text.
+   * @param  {Object} options
+   *         The options object in use; @see _mergeOptions.
+   * @param  {CSSLexer} tokenStream
+   *         The token stream from which to read
+   * @param  {Boolean} stopAtCloseParen
+   *         If true, stop at an umatched close paren.
    * @return {DocumentFragment}
    *         A document fragment.
    */
-  _parse: function (text, options = {}) {
-    text = text.trim();
-    this.parsed.length = 0;
-
-    let tokenStream = getCSSLexer(text);
-    let parenDepth = 0;
+  _doParse: function (text, options, tokenStream, stopAtCloseParen) {
+    let parenDepth = stopAtCloseParen ? 1 : 0;
     let outerMostFunctionTakesColor = false;
 
     let colorOK = function () {
       return options.supportsColor ||
         (options.expectFilter && parenDepth === 1 &&
          outerMostFunctionTakesColor);
     };
 
     let angleOK = function (angle) {
       return (new angleUtils.CssAngle(angle)).valid;
     };
 
     let spaceNeeded = false;
-    let token = tokenStream.nextToken();
-    while (token) {
+    let done = false;
+
+    while (!done) {
+      let token = tokenStream.nextToken();
+      if (!token) {
+        break;
+      }
+
       if (token.tokenType === "comment") {
         // This doesn't change spaceNeeded, because we didn't emit
         // anything to the output.
-        token = tokenStream.nextToken();
         continue;
       }
 
       switch (token.tokenType) {
         case "function": {
           if (COLOR_TAKING_FUNCTIONS.includes(token.text) ||
               ANGLE_TAKING_FUNCTIONS.includes(token.text)) {
             // The function can accept a color or an angle argument, and we know
@@ -185,31 +312,54 @@ OutputParser.prototype = {
             // can be handled in a single place.
             this._appendTextNode(text.substring(token.startOffset,
                                                 token.endOffset));
             if (parenDepth === 0) {
               outerMostFunctionTakesColor = COLOR_TAKING_FUNCTIONS.includes(
                 token.text);
             }
             ++parenDepth;
+          } else if (token.text === "var" && options.isVariableInUse) {
+            let variableNode = this._parseVariable(token, text, tokenStream, options);
+            this.parsed.push(variableNode);
           } else {
-            let functionText = this._collectFunctionText(token, text,
-                                                         tokenStream);
+            let {functionData, sawVariable} = this._parseMatchingParens(text, tokenStream,
+              options);
+
+            let functionName = text.substring(token.startOffset, token.endOffset);
 
-            if (options.expectCubicBezier && token.text === "cubic-bezier") {
-              this._appendCubicBezier(functionText, options);
-            } else if (colorOK() &&
-                       colorUtils.isValidCSSColor(functionText, this.cssColor4)) {
-              this._appendColor(functionText, options);
-            } else if (options.expectShape &&
-                       Services.prefs.getBoolPref(CSS_SHAPES_ENABLED_PREF) &&
-                       BASIC_SHAPE_FUNCTIONS.includes(token.text)) {
-              this._appendShape(functionText, options);
+            if (sawVariable) {
+              // If function contains variable, we need to add both strings
+              // and nodes.
+              this._appendTextNode(functionName);
+              for (let data of functionData) {
+                if (typeof data === "string") {
+                  this._appendTextNode(data);
+                } else if (data) {
+                  this.parsed.push(data);
+                }
+              }
+              this._appendTextNode(")");
             } else {
-              this._appendTextNode(functionText);
+              // If no variable in function, join the text together and add
+              // to DOM accordingly.
+              let functionText = functionName + functionData.join("") + ")";
+
+              if (options.expectCubicBezier && token.text === "cubic-bezier") {
+                this._appendCubicBezier(functionText, options);
+              } else if (colorOK() &&
+                         colorUtils.isValidCSSColor(functionText, this.cssColor4)) {
+                this._appendColor(functionText, options);
+              } else if (options.expectShape &&
+                         Services.prefs.getBoolPref(CSS_SHAPES_ENABLED_PREF) &&
+                         BASIC_SHAPE_FUNCTIONS.includes(token.text)) {
+                this._appendShape(functionText, options);
+              } else {
+                this._appendTextNode(functionText);
+              }
             }
           }
           break;
         }
 
         case "ident":
           if (options.expectCubicBezier &&
               BEZIER_KEYWORDS.indexOf(token.text) >= 0) {
@@ -257,16 +407,22 @@ OutputParser.prototype = {
                           token.text, options);
           break;
 
         case "symbol":
           if (token.text === "(") {
             ++parenDepth;
           } else if (token.text === ")") {
             --parenDepth;
+
+            if (stopAtCloseParen && parenDepth === 0) {
+              done = true;
+              break;
+            }
+
             if (parenDepth === 0) {
               outerMostFunctionTakesColor = false;
             }
           }
           // falls through
         default:
           this._appendTextNode(
             text.substring(token.startOffset, token.endOffset));
@@ -274,28 +430,46 @@ OutputParser.prototype = {
       }
 
       // If this token might possibly introduce token pasting when
       // color-cycling, require a space.
       spaceNeeded = (token.tokenType === "ident" || token.tokenType === "at" ||
                      token.tokenType === "id" || token.tokenType === "hash" ||
                      token.tokenType === "number" || token.tokenType === "dimension" ||
                      token.tokenType === "percentage" || token.tokenType === "dimension");
-
-      token = tokenStream.nextToken();
     }
 
     let result = this._toDOM();
 
     if (options.expectFilter && !options.filterSwatch) {
       result = this._wrapFilter(text, options, result);
     }
 
     return result;
   },
+  /* eslint-enable complexity */
+
+  /**
+   * Parse a string.
+   *
+   * @param  {String} text
+   *         Text to parse.
+   * @param  {Object} [options]
+   *         Options object. For valid options and default values see
+   *         _mergeOptions().
+   * @return {DocumentFragment}
+   *         A document fragment.
+   */
+  _parse: function (text, options = {}) {
+    text = text.trim();
+    this.parsed.length = 0;
+
+    let tokenStream = getCSSLexer(text);
+    return this._doParse(text, options, tokenStream, false);
+  },
 
   /**
    * Return true if it's a display:[inline-]grid token.
    *
    * @param  {String} text
    *         the parsed text.
    * @param  {Object} token
    *         the parsed token.
@@ -1237,16 +1411,24 @@ OutputParser.prototype = {
    *                                    // _wrapFilter.  Used only for
    *                                    // previewing with the filter swatch.
    *           - gridClass: ""          // The class to use for the grid icon.
    *           - shapeClass: ""         // The class to use for the shape icon.
    *           - supportsColor: false   // Does the CSS property support colors?
    *           - urlClass: ""           // The class to be used for url() links.
    *           - baseURI: undefined     // A string used to resolve
    *                                    // relative links.
+   *           - isVariableInUse        // A function taking a single
+   *                                    // argument, the name of a variable.
+   *                                    // This should return the variable's
+   *                                    // value, if it is in use; or null.
+   *           - unmatchedVariableClass: ""
+   *                                    // The class to use for a component
+   *                                    // of a "var(...)" that is not in
+   *                                    // use.
    * @return {Object}
    *         Overridden options object
    */
   _mergeOptions: function (overrides) {
     let defaults = {
       defaultColorType: true,
       angleClass: "",
       angleSwatchClass: "",
@@ -1255,16 +1437,18 @@ OutputParser.prototype = {
       colorClass: "",
       colorSwatchClass: "",
       filterSwatch: false,
       gridClass: "",
       shapeClass: "",
       supportsColor: false,
       urlClass: "",
       baseURI: undefined,
+      isVariableInUse: null,
+      unmatchedVariableClass: null,
     };
 
     for (let item in overrides) {
       defaults[item] = overrides[item];
     }
     return defaults;
   }
 };
--- a/devtools/client/shared/test/browser_outputparser.js
+++ b/devtools/client/shared/test/browser_outputparser.js
@@ -24,16 +24,17 @@ function* performTest() {
 
   let parser = new OutputParser(doc, cssProperties);
   testParseCssProperty(doc, parser);
   testParseCssVar(doc, parser);
   testParseURL(doc, parser);
   testParseFilter(doc, parser);
   testParseAngle(doc, parser);
   testParseShape(doc, parser);
+  testParseVariable(doc, parser);
 
   host.destroy();
 }
 
 // Class name used in color swatch.
 var COLOR_TEST_CLASS = "test-class";
 
 // Create a new CSS color-parsing test.  |name| is the name of the CSS
@@ -408,8 +409,50 @@ function testParseShape(doc, parser) {
     let frag = parser.parseCssProperty("clip-path", definition, {
       shapeClass: "ruleview-shape"
     });
     let spans = frag.querySelectorAll(".ruleview-shape-point");
     is(spans.length, spanCount, desc + " span count");
     is(frag.textContent, definition, desc + " text content");
   }
 }
+
+function testParseVariable(doc, parser) {
+  let TESTS = [
+    {
+      text: "var(--seen)",
+      variables: {"--seen": "chartreuse" },
+      expected: "var(<span title=\"--seen = chartreuse\">--seen</span>)"
+    },
+    {
+      text: "var(--not-seen)",
+      variables: {},
+      expected: "var(<span class=\"unmatched-class\" title=\"--not-seen is not set\">--not-seen</span>)"
+    },
+    {
+      text: "var(--seen, seagreen)",
+      variables: {"--seen": "chartreuse" },
+      expected: "var(<span title=\"--seen = chartreuse\">--seen</span>,<span class=\"unmatched-class\"> <span data-color=\"seagreen\"><span>seagreen</span></span></span>)"
+    },
+    {
+      text: "var(--not-seen, var(--seen))",
+      variables: {"--seen": "chartreuse" },
+      expected: "var(<span class=\"unmatched-class\" title=\"--not-seen is not set\">--not-seen</span>,<span> var(<span title=\"--seen = chartreuse\">--seen</span>)</span>)"
+    },
+  ];
+
+  for (let test of TESTS) {
+    let getValue = function (varName) {
+      return test.variables[varName];
+    };
+
+    let frag = parser.parseCssProperty("color", test.text, {
+      isVariableInUse: getValue,
+      unmatchedVariableClass: "unmatched-class"
+    });
+
+    let target = doc.querySelector("div");
+    target.appendChild(frag);
+
+    is(target.innerHTML, test.expected, test.text);
+    target.innerHTML = "";
+  }
+}
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -561,17 +561,18 @@
 }
 
 .ruleview-selectorcontainer {
   word-wrap: break-word;
   cursor: text;
 }
 
 .ruleview-selector-separator,
-.ruleview-selector-unmatched {
+.ruleview-selector-unmatched,
+.ruleview-variable-unmatched {
   color: #888;
 }
 
 .ruleview-selector-matched > .ruleview-selector-attribute {
   /* TODO: Bug 1178535 Awaiting UX feedback on highlight colors */
 }
 
 .ruleview-selector-matched > .ruleview-selector-pseudo-class {
--- a/devtools/shared/fronts/css-properties.js
+++ b/devtools/shared/fronts/css-properties.js
@@ -144,17 +144,18 @@ CssProperties.prototype = {
 
   /**
    * Checks to see if the property is an inherited one.
    *
    * @param {String} property The property name to be checked.
    * @return {Boolean}
    */
   isInherited(property) {
-    return this.properties[property] && this.properties[property].isInherited;
+    return (this.properties[property] && this.properties[property].isInherited) ||
+            isCssVariable(property);
   },
 
   /**
    * Checks if the property supports the given CSS type.
    * CSS types should come from devtools/shared/css/properties-db.js' CSS_TYPES.
    *
    * @param {String} property The property to be checked.
    * @param {Number} type One of the type values from CSS_TYPES.
@@ -345,10 +346,11 @@ function reattachCssColorValues(db) {
   }
 }
 
 module.exports = {
   CssPropertiesFront,
   CssProperties,
   getCssProperties,
   getClientCssProperties,
-  initCssProperties
+  initCssProperties,
+  isCssVariable,
 };
--- a/devtools/shared/locales/en-US/styleinspector.properties
+++ b/devtools/shared/locales/en-US/styleinspector.properties
@@ -62,16 +62,28 @@ rule.warning.title=Invalid property valu
 # of the search button that is shown next to a property that has been overridden
 # in the rule view.
 rule.filterProperty.title=Filter rules containing this property
 
 # LOCALIZATION NOTE (ruleView.empty): Text displayed when the highlighter is
 # first opened and there's no node selected in the rule view.
 rule.empty=No element selected.
 
+# LOCALIZATION NOTE (rule.variableValue): Text displayed in a tooltip
+# when the mouse is over a variable use (like "var(--something)") in
+# the rule view.  The first argument is the variable name and the
+# second argument is the value.
+rule.variableValue=%S = %S
+
+# LOCALIZATION NOTE (rule.variableUnset): Text displayed in a tooltip
+# when the mouse is over a variable use (like "var(--something)"),
+# where the variable is not set.  the rule view.  The argument is the
+# variable name.
+rule.variableUnset=%S is not set
+
 # LOCALIZATION NOTE (ruleView.selectorHighlighter.tooltip): Text displayed in a
 # tooltip when the mouse is over a selector highlighter icon in the rule view.
 rule.selectorHighlighter.tooltip=Highlight all elements matching this selector
 
 # LOCALIZATION NOTE (rule.colorSwatch.tooltip): Text displayed in a tooltip
 # when the mouse is over a color swatch in the rule view.
 rule.colorSwatch.tooltip=Click to open the color picker, Shift+click to change the color format