Bug 1229858: Add a preprocessor to convert XBL into JavaScript blocks for eslint. r=miker
authorDave Townsend <dtownsend@oxymoronical.com>
Wed, 02 Dec 2015 17:03:33 -0800
changeset 275965 6c9319ab4430fd2858325fd1546d8fcc8fe513d7
parent 275964 64fc74a36a1606092a3cf23b949773b0518908cb
child 275966 d4a2076b059234d5fbd9397bd6ba3c112181ae80
push id16595
push userdtownsend@mozilla.com
push dateWed, 09 Dec 2015 15:29:17 +0000
treeherderfx-team@6c9319ab4430 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmiker
bugs1229858
milestone45.0a1
Bug 1229858: Add a preprocessor to convert XBL into JavaScript blocks for eslint. r=miker
python/mach_commands.py
testing/eslint-plugin-mozilla/lib/index.js
testing/eslint-plugin-mozilla/lib/processors/xbl-bindings.js
testing/eslint-plugin-mozilla/package.json
--- a/python/mach_commands.py
+++ b/python/mach_commands.py
@@ -167,18 +167,18 @@ class MachCommands(MachCommandBase):
     @Command('eslint', category='devenv',
         description='Run eslint or help configure eslint for optimal development.')
     @CommandArgument('-s', '--setup', default=False, action='store_true',
         help='configure eslint for optimal development.')
     @CommandArgument('path', nargs='?', default='.',
         help='Path to files to lint, like "browser/components/loop" '
             'or "mobile/android". '
             'Defaults to the current directory if not given.')
-    @CommandArgument('-e', '--ext', default='[.js,.jsm,.jsx]',
-        help='Filename extensions to lint, default: "[.js,.jsm,.jsx]".')
+    @CommandArgument('-e', '--ext', default='[.js,.jsm,.jsx,.xml]',
+        help='Filename extensions to lint, default: "[.js,.jsm,.jsx,.xml]".')
     @CommandArgument('-b', '--binary', default=None,
         help='Path to eslint binary.')
     @CommandArgument('args', nargs=argparse.REMAINDER)  # Passed through to eslint.
     def eslint(self, setup, path, ext=None, binary=None, args=[]):
         '''Run eslint.'''
 
         if setup:
             return self.eslint_setup()
--- a/testing/eslint-plugin-mozilla/lib/index.js
+++ b/testing/eslint-plugin-mozilla/lib/index.js
@@ -8,16 +8,19 @@
 
 "use strict";
 
 //------------------------------------------------------------------------------
 // Plugin Definition
 //------------------------------------------------------------------------------
 
 module.exports = {
+  processors: {
+    ".xml": require("../lib/processors/xbl-bindings"),
+  },
   rules: {
     "balanced-listeners": require("../lib/rules/balanced-listeners"),
     "components-imports": require("../lib/rules/components-imports"),
     "import-headjs-globals": require("../lib/rules/import-headjs-globals"),
     "mark-test-function-used": require("../lib/rules/mark-test-function-used"),
     "no-aArgs": require("../lib/rules/no-aArgs"),
     "no-cpows-in-tests": require("../lib/rules/no-cpows-in-tests"),
     "var-only-at-top-level": require("../lib/rules/var-only-at-top-level")
new file mode 100644
--- /dev/null
+++ b/testing/eslint-plugin-mozilla/lib/processors/xbl-bindings.js
@@ -0,0 +1,249 @@
+/**
+ * @fileoverview Converts functions and handlers from XBL bindings into JS
+ * functions
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+const NS_XBL = "http://www.mozilla.org/xbl";
+
+let sax = require("sax");
+
+// Converts sax's error message to something that eslint will understand
+let errorRegex = /(.*)\nLine: (\d+)\nColumn: (\d+)\nChar: (.*)/
+function parseError(err) {
+  let matches = err.message.match(errorRegex);
+  if (!matches)
+    return null;
+
+  return {
+    fatal: true,
+    message: matches[1],
+    line: parseInt(matches[2]) + 1,
+    column: parseInt(matches[3])
+  }
+}
+
+// A simple sax listener that generates a tree of element information
+function XMLParser(parser) {
+  this.parser = parser;
+  parser.onopentag = this.onOpenTag.bind(this);
+  parser.onclosetag = this.onCloseTag.bind(this);
+  parser.ontext = this.onText.bind(this);
+  parser.oncdata = this.onText.bind(this);
+
+  this.document = {
+    local: "#document",
+    uri: null,
+    children: [],
+  }
+  this._currentNode = this.document;
+}
+
+XMLParser.prototype = {
+  parser: null,
+
+  onOpenTag: function(tag) {
+    let node = {
+      parentNode: this._currentNode,
+      local: tag.local,
+      namespace: tag.uri,
+      attributes: {},
+      children: [],
+      textContent: "",
+      textStart: this.parser.line,
+    }
+
+    for (let attr of Object.keys(tag.attributes)) {
+      if (tag.attributes[attr].uri == "") {
+        node.attributes[attr] = tag.attributes[attr].value;
+      }
+    }
+
+    this._currentNode.children.push(node);
+    this._currentNode = node;
+  },
+
+  onCloseTag: function(tagname) {
+    this._currentNode = this._currentNode.parentNode;
+  },
+
+  onText: function(text) {
+    this._currentNode.textContent += text;
+  }
+}
+
+// Strips the indentation from lines of text and adds a fixed two spaces indent
+function reindent(text) {
+  let lines = text.split("\n");
+
+  // The last line is likely indentation for the XML closing tag.
+  if (lines[lines.length - 1].trim() == "") {
+    lines.pop();
+  }
+
+  if (!lines.length) {
+    return "";
+  }
+
+  // Find the preceeding whitespace for all lines that aren't entirely whitespace
+  let indents = lines.filter(s => s.trim().length > 0)
+                     .map(s => s.length - s.trimLeft().length);
+  // Find the smallest indent level in use
+  let minIndent = Math.min(...indents);
+
+  // Strip off the found indent level and prepend the new indent level, but only
+  // if the string isn't already empty.
+  lines = lines.map(s => s.length > 0 ? "  " + s.substring(minIndent) : s);
+  return lines.join("\n") + "\n";
+}
+
+// -----------------------------------------------------------------------------
+// Processor Definition
+// -----------------------------------------------------------------------------
+
+// Stores any XML parse error
+let xmlParseError = null;
+
+// Stores the starting line for each script block generated
+let blockLines = [];
+
+module.exports = {
+  preprocess: function(text, filename) {
+    xmlParseError = null;
+    blockLines = [];
+
+    // Non-strict allows us to ignore many errors from entities and
+    // preprocessing at the expense of failing to report some XML errors.
+    // Unfortunately it also throws away the case of tagnames and attributes
+    let parser = sax.parser(false, {
+      lowercase: true,
+      xmlns: true,
+    });
+
+    parser.onerror = function(err) {
+      xmlParseError = parseError(err);
+    }
+
+    let xp = new XMLParser(parser);
+    parser.write(text);
+
+    // Sanity checks to make sure we're dealing with an XBL document
+    let document = xp.document;
+    if (document.children.length != 1) {
+      return [];
+    }
+
+    let bindings = document.children[0];
+    if (bindings.local != "bindings" || bindings.namespace != NS_XBL) {
+      return [];
+    }
+
+    let scripts = [];
+
+    for (let binding of bindings.children) {
+      if (binding.local != "binding" || binding.namespace != NS_XBL) {
+        continue;
+      }
+
+      for (let part of binding.children) {
+        if (part.namespace != NS_XBL) {
+          continue;
+        }
+
+        if (part.local != "implementation" && part.local != "handlers") {
+          continue;
+        }
+
+        for (let item of part.children) {
+          if (item.namespace != NS_XBL) {
+            continue;
+          }
+
+          switch (item.local) {
+            case "field": {
+              // Fields get converted into variable declarations
+              let def = item.textContent.trimRight();
+              // Ignore empty fields
+              if (def.trim().length == 0) {
+                continue;
+              }
+              blockLines.push(item.textStart);
+              scripts.push(`let ${item.attributes.name} = ${def}\n`);
+              break;
+            }
+            case "constructor":
+            case "destructor": {
+              // Constructors and destructors become function declarations
+              blockLines.push(item.textStart);
+              let content = reindent(item.textContent);
+              scripts.push(`function ${item.local}() {${content}}\n`);
+              break;
+            }
+            case "method": {
+              // Methods become function declarations with the appropriate params
+              let params = item.children.filter(n => n.local == "parameter" && n.namespace == NS_XBL)
+                                        .map(n => n.attributes.name)
+                                        .join(", ");
+              let body = item.children.filter(n => n.local == "body" && n.namespace == NS_XBL)[0];
+              blockLines.push(body.textStart);
+              body = reindent(body.textContent);
+              scripts.push(`function ${item.attributes.name}(${params}) {${body}}\n`)
+              break;
+            }
+            case "property": {
+              // Properties become one or two function declarations
+              for (let propdef of item.children) {
+                if (propdef.namespace != NS_XBL) {
+                  continue;
+                }
+
+                blockLines.push(propdef.textStart);
+                let content = reindent(propdef.textContent);
+                let params = propdef.local == "setter" ? "val" : "";
+                scripts.push(`function ${item.attributes.name}_${propdef.local}(${params}) {${content}}\n`);
+              }
+              break;
+            }
+            case "handler": {
+              // Handlers become a function declaration with an `event` parameter
+              blockLines.push(item.textStart);
+              let content = reindent(item.textContent);
+              scripts.push(`function on${item.attributes.event}(event) {${content}}\n`);
+              break;
+            }
+            default:
+              continue;
+          }
+        }
+      }
+    }
+
+    return scripts;
+  },
+
+  postprocess: function(messages, filename) {
+    // If there was an XML parse error then just return that
+    if (xmlParseError) {
+      return [xmlParseError];
+    }
+
+    // For every message from every script block update the line to point to the
+    // correct place.
+    let errors = [];
+    for (let i = 0; i < messages.length; i++) {
+      let line = blockLines[i];
+
+      for (let message of messages[i]) {
+        message.line += line;
+        errors.push(message);
+      }
+    }
+
+    return errors;
+  }
+};
--- a/testing/eslint-plugin-mozilla/package.json
+++ b/testing/eslint-plugin-mozilla/package.json
@@ -12,15 +12,16 @@
   "bugs": {
     "url": "https://bugzilla.mozilla.org/enter_bug.cgi?product=Firefox&component=Developer%20Tools"
   },
   "homepage": "https://bugzilla.mozilla.org/show_bug.cgi?id=1203520",
   "author": "Mike Ratcliffe",
   "main": "lib/index.js",
   "dependencies": {
     "escope": "^3.2.0",
-    "espree": "^2.2.4"
+    "espree": "^2.2.4",
+    "sax": "^1.1.4"
   },
   "engines": {
     "node": ">=0.10.0"
   },
   "license": "MPL-2.0"
 }