Repository: yannickcr/eslint-plugin-react (js)

Issue:
Rule proposal: order of component methods
Some people like to order component methods in the order of its lifecycle. This is mentioned in https://reactjsnews.com/react-style-guide-patterns-i-like/ and the example he uses is:

```js
React.createClass({  
  displayName : '',
  propTypes: {},
  mixins : [],
  getInitialState : function() {},
  componentWillMount : function() {},
  componentWillUnmount : function() {},
  _onChange : function() {},
  _onCreate : function() {},
  render : function() {}
});
```

The list of lifecycle methods can be found at https://facebook.github.io/react/docs/component-specs.html

Pull request description:
Add sort-comp rule (fixes #39)

Patch:
diff --git a/README.md b/README.md
index b3d28811..638c1342 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ Finally, enable all of the rules that you would like to use.
     "react/prop-types": 1,
     "react/react-in-jsx-scope": 1,
     "react/self-closing-comp": 1,
+    "react/sort-comp": 1,
     "react/wrap-multilines": 1
   }
 }
@@ -79,6 +80,7 @@ Finally, enable all of the rules that you would like to use.
 * [prop-types](docs/rules/prop-types.md): Prevent missing props validation in a React component definition
 * [react-in-jsx-scope](docs/rules/react-in-jsx-scope.md): Prevent missing React when using JSX
 * [self-closing-comp](docs/rules/self-closing-comp.md): Prevent extra closing tags for components without children
+* [sort-comp](docs/rules/sort-comp.md): Enforce component methods order
 * [wrap-multilines](docs/rules/wrap-multilines.md): Prevent missing parentheses around multilines JSX
 
 ## To Do
diff --git a/docs/rules/sort-comp.md b/docs/rules/sort-comp.md
new file mode 100644
index 00000000..78067294
--- /dev/null
+++ b/docs/rules/sort-comp.md
@@ -0,0 +1,163 @@
+# Enforce component methods order (sort-comp)
+
+When creating React components it is more convenient to always follow the same organisation for methods order to helps you to easily find lifecyle methods, event handlers, etc.
+
+## Rule Details
+
+With default configuration the following organisation must be followed:
+
+  1. lifecycle methods: `displayName` ,`propTypes` ,`mixins` ,`statics` ,`getDefaultProps` ,`getInitialState` ,`componentWillMount` ,`componentDidMount` ,`componentWillReceiveProps` ,`shouldComponentUpdate` ,`componentWillUpdate` ,`componentDidUpdate` ,`componentWillUnmount` (in this order).
+  2. custom methods
+  3. `render` method
+
+The following patterns are considered warnings:
+
+```js
+var Hello = React.createClass({
+  render: function() {
+    return <div>Hello</div>;
+  },
+  displayName : 'Hello'
+});
+```
+
+The following patterns are not considered warnings:
+
+```js
+var Hello = React.createClass({
+  displayName : 'Hello',
+  render: function() {
+    return <div>Hello</div>;
+  }
+});
+```
+
+## Rule Options
+
+This rule can take one argument to customize the components organisation.
+
+```
+...
+"react/sort-comp": [<enabled>, { order: <order>, groups: <groups> }]
+...
+```
+
+* `enabled`: for enabling the rule. 0=off, 1=warn, 2=error. Defaults to 0.
+* `order`: optional array of methods to validate.
+* `groups`: optional object of methods groups.
+
+The default configuration is:
+
+```js
+{
+  order: [
+    'lifecycle',
+    'everything-else',
+    'render'
+  ],
+  groups: {
+    lifecycle: [
+      'displayName',
+      'propTypes',
+      'mixins',
+      'statics',
+      'getDefaultProps',
+      'getInitialState',
+      'componentWillMount',
+      'componentDidMount',
+      'componentWillReceiveProps',
+      'shouldComponentUpdate',
+      'componentWillUpdate',
+      'componentDidUpdate',
+      'componentWillUnmount'
+    ]
+  }
+}
+```
+
+* `lifecycle` is refering to the `lifecycle` group defined in `groups`.
+* `everything-else` is a special group that match all the methods that do not match any of the other groups.
+* `render` is refering to the `render` method.
+
+You can override this configuration to match your needs.
+
+For example, if you want to place your event handlers (`onClick`, `onSubmit`, etc.) before `render` but the other methods after it:
+
+```js
+"react/sort-comp": [1, {
+  order: [
+    'lifecycle',
+    '/^on.+$/',
+    'render',
+    'everything-else'
+  ]
+}]
+```
+
+With the above configuration, the following patterns are considered warnings:
+
+```js
+var Hello = React.createClass({
+  render: function() {
+    return <div>Hello</div>;
+  },
+  onClick: function() {}
+});
+```
+
+With the above configuration, the following patterns are not considered warnings:
+
+```js
+var Hello = React.createClass({
+  onClick: function() {},
+  render: function() {
+    return <div>Hello</div>;
+  }
+});
+```
+
+If you want to split your `render` method into smaller ones and keep them just before render:
+
+```js
+"react/sort-comp": [1, {
+  order: [
+    'lifecycle',
+    'everything-else',
+    'rendering',
+  ],
+  groups: {
+    rendering: [
+      '/^render.+$/',
+      'render'
+    ]
+  }
+}]
+```
+
+With the above configuration, the following patterns are considered warnings:
+
+```js
+var Hello = React.createClass({
+  renderButton: function() {},
+  onClick: function() {},
+  render: function() {
+    return <div>Hello</div>;
+  }
+});
+```
+
+With the above configuration, the following patterns are not considered warnings:
+
+```js
+var Hello = React.createClass({
+  onClick: function() {},
+  renderButton: function() {},
+  render: function() {
+    return <div>Hello</div>;
+  }
+});
+```
+
+## When Not To Use It
+
+This rule is a formatting preference and not following it won't negatively affect the quality of your code. If components organisation isn't a part of your coding standards, then you can leave this rule off.
diff --git a/index.js b/index.js
index aaa6f682..c467477b 100644
--- a/index.js
+++ b/index.js
@@ -17,7 +17,8 @@ module.exports = {
     'no-unknown-property': require('./lib/rules/no-unknown-property'),
     'jsx-sort-props': require('./lib/rules/jsx-sort-props'),
     'jsx-sort-prop-types': require('./lib/rules/jsx-sort-prop-types'),
-    'jsx-boolean-value': require('./lib/rules/jsx-boolean-value')
+    'jsx-boolean-value': require('./lib/rules/jsx-boolean-value'),
+    'sort-comp': require('./lib/rules/sort-comp')
   },
   rulesConfig: {
     'jsx-uses-react': 0,
@@ -35,6 +36,7 @@ module.exports = {
     'no-unknown-property': 0,
     'jsx-sort-props': 0,
     'jsx-sort-prop-types': 0,
-    'jsx-boolean-value': 0
+    'jsx-boolean-value': 0,
+    'sort-comp': 0
   }
 };
diff --git a/lib/rules/sort-comp.js b/lib/rules/sort-comp.js
new file mode 100644
index 00000000..6e2f1c9d
--- /dev/null
+++ b/lib/rules/sort-comp.js
@@ -0,0 +1,367 @@
+/**
+ * @fileoverview Enforce component methods order
+ * @author Yannick Croissant
+ */
+'use strict';
+
+var util = require('util');
+
+var componentUtil = require('../util/component');
+var ComponentList = componentUtil.List;
+
+/**
+ * Get the methods order from the default config and the user config
+ * @param {Object} defaultConfig The default configuration.
+ * @param {Object} userConfig The user configuration.
+ * @returns {Array} Methods order
+ */
+function getMethodsOrder(defaultConfig, userConfig) {
+  userConfig = userConfig || {};
+
+  var groups = util._extend(defaultConfig.groups, userConfig.groups);
+  var order = userConfig.order || defaultConfig.order;
+
+  var config = [];
+  var entry;
+  for (var i = 0, j = order.length; i < j; i++) {
+    entry = order[i];
+    if (groups[entry]) {
+      config = config.concat(groups[entry]);
+    } else {
+      config.push(entry);
+    }
+  }
+
+  return config;
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = function(context) {
+
+  var componentList = new ComponentList();
+  var errors = {};
+
+  var MISPOSITION_MESSAGE = '{{propA}} must be placed {{position}} {{propB}}';
+
+  var methodsOrder = getMethodsOrder({
+    order: [
+      'lifecycle',
+      'everything-else',
+      'render'
+    ],
+    groups: {
+      lifecycle: [
+        'displayName',
+        'propTypes',
+        'mixins',
+        'statics',
+        'getDefaultProps',
+        'getInitialState',
+        'componentWillMount',
+        'componentDidMount',
+        'componentWillReceiveProps',
+        'shouldComponentUpdate',
+        'componentWillUpdate',
+        'componentDidUpdate',
+        'componentWillUnmount'
+      ]
+    }
+  }, context.options[0]);
+
+  /**
+   * Checks if the component must be validated
+   * @param {Object} component The component to process
+   * @returns {Boolean} True if the component must be validated, false if not.
+   */
+  function mustBeValidated(component) {
+    return (
+      component &&
+      component.isReactComponent &&
+      !component.hasDisplayName
+    );
+  }
+
+  // --------------------------------------------------------------------------
+  // Public
+  // --------------------------------------------------------------------------
+
+  var regExpRegExp = /\/(.*)\/([g|y|i|m]*)/;
+
+  /**
+   * Get indexes of the matching patterns in methods order configuration
+   * @param {String} method - Method name.
+   * @returns {Array} The matching patterns indexes. Return [Infinity] if there is no match.
+   */
+  function getRefPropIndexes(method) {
+    var isRegExp;
+    var matching;
+    var i;
+    var j;
+    var indexes = [];
+    for (i = 0, j = methodsOrder.length; i < j; i++) {
+      isRegExp = methodsOrder[i].match(regExpRegExp);
+      if (isRegExp) {
+        matching = new RegExp(isRegExp[1], isRegExp[2]).test(method);
+      } else {
+        matching = methodsOrder[i] === method;
+      }
+      if (matching) {
+        indexes.push(i);
+      }
+    }
+
+    // No matching pattern, return 'everything-else' index
+    if (indexes.length === 0) {
+      for (i = 0, j = methodsOrder.length; i < j; i++) {
+        if (methodsOrder[i] === 'everything-else') {
+          indexes.push(i);
+        }
+      }
+    }
+
+    // No matching pattern and no 'everything-else' group
+    if (indexes.length === 0) {
+      indexes.push(Infinity);
+    }
+
+    return indexes;
+  }
+
+  /**
+   * Get properties name
+   * @param {Object} node - Property.
+   * @returns {String} Property name.
+   */
+  function getPropertyName(node) {
+
+    // Special case for class properties
+    // (babel-eslint does not expose property name so we have to rely on tokens)
+    if (node.type === 'ClassProperty') {
+      var tokens = context.getFirstTokens(node, 2);
+      return tokens[1].type === 'Identifier' ? tokens[1].value : tokens[0].value;
+    }
+
+    return node.key.name;
+  }
+
+  /**
+   * Store a new error in the error list
+   * @param {Object} propA - Mispositioned property.
+   * @param {Object} propB - Reference property.
+   */
+  function storeError(propA, propB) {
+    // Initialize the error object if needed
+    if (!errors[propA.index]) {
+      errors[propA.index] = {
+        node: propA.node,
+        score: 0,
+        closest: {
+          distance: Infinity,
+          ref: {
+            node: null,
+            index: 0
+          }
+        }
+      };
+    }
+    // Increment the prop score
+    errors[propA.index].score++;
+    // Stop here if we already have a closer reference
+    if (Math.abs(propA.index - propB.index) > errors[propA.index].closest.distance) {
+      return;
+    }
+    // Update the closest reference
+    errors[propA.index].closest.distance = Math.abs(propA.index - propB.index);
+    errors[propA.index].closest.ref.node = propB.node;
+    errors[propA.index].closest.ref.index = propB.index;
+  }
+
+  /**
+   * Dedupe errors, only keep the ones with the highest score and delete the others
+   */
+  function dedupeErrors() {
+    for (var i in errors) {
+      if (!errors.hasOwnProperty(i)) {
+        continue;
+      }
+      var index = errors[i].closest.ref.index;
+      if (!errors[index]) {
+        continue;
+      }
+      if (errors[i].score > errors[index].score) {
+        delete errors[index];
+      } else {
+        delete errors[i];
+      }
+    }
+  }
+
+  /**
+   * Report errors
+   */
+  function reportErrors() {
+    dedupeErrors();
+
+    var nodeA;
+    var nodeB;
+    var indexA;
+    var indexB;
+    for (var i in errors) {
+      if (!errors.hasOwnProperty(i)) {
+        continue;
+      }
+
+      nodeA = errors[i].node;
+      nodeB = errors[i].closest.ref.node;
+      indexA = i;
+      indexB = errors[i].closest.ref.index;
+
+      context.report(nodeA, MISPOSITION_MESSAGE, {
+        propA: getPropertyName(nodeA),
+        propB: getPropertyName(nodeB),
+        position: indexA < indexB ? 'before' : 'after'
+      });
+    }
+  }
+
+  /**
+   * Get properties for a given AST node
+   * @param {ASTNode} node The AST node being checked.
+   * @returns {Array} Properties array.
+   */
+  function getComponentProperties(node) {
+    if (node.type === 'ClassDeclaration') {
+      return node.body.body;
+    }
+    return node.properties;
+  }
+
+  /**
+   * Compare two properties and find out if they are in the right order
+   * @param {Array} propertiesNames Array containing all the properties names.
+   * @param {String} propA First property name.
+   * @param {String} propB Second property name.
+   * @returns {Object} Object containing a correct true/false flag and the correct indexes for the two properties.
+   */
+  function comparePropsOrder(propertiesNames, propA, propB) {
+    var i;
+    var j;
+    var k;
+    var l;
+    var refIndexA;
+    var refIndexB;
+
+    // Get references indexes (the correct position) for given properties
+    var refIndexesA = getRefPropIndexes(propA);
+    var refIndexesB = getRefPropIndexes(propB);
+
+    // Get current indexes for given properties
+    var classIndexA = propertiesNames.indexOf(propA);
+    var classIndexB = propertiesNames.indexOf(propB);
+
+    // Loop around the references indexes for the 1st property
+    for (i = 0, j = refIndexesA.length; i < j; i++) {
+      refIndexA = refIndexesA[i];
+
+      // Loop around the properties for the 2nd property (for comparison)
+      for (k = 0, l = refIndexesB.length; k < l; k++) {
+        refIndexB = refIndexesB[k];
+
+        if (
+          // Comparing the same properties
+          refIndexA === refIndexB ||
+          // 1st property is placed before the 2nd one in reference and in current component
+          refIndexA < refIndexB && classIndexA < classIndexB ||
+          // 1st property is placed after the 2nd one in reference and in current component
+          refIndexA > refIndexB && classIndexA > classIndexB
+        ) {
+          return {
+            correct: true,
+            indexA: classIndexA,
+            indexB: classIndexB
+          };
+        }
+
+      }
+    }
+
+    // We did not find any correct match between reference and current component
+    return {
+      correct: false,
+      indexA: refIndexA,
+      indexB: refIndexB
+    };
+  }
+
+  /**
+   * Check properties order from a properties list and store the eventual errors
+   * @param {Array} properties Array containing all the properties.
+   */
+  function checkPropsOrder(properties) {
+    var propertiesNames = properties.map(getPropertyName);
+    var i;
+    var j;
+    var k;
+    var l;
+    var propA;
+    var propB;
+    var order;
+
+    // Loop around the properties
+    for (i = 0, j = propertiesNames.length; i < j; i++) {
+      propA = propertiesNames[i];
+
+      // Loop around the properties a second time (for comparison)
+      for (k = 0, l = propertiesNames.length; k < l; k++) {
+        propB = propertiesNames[k];
+
+        // Compare the properties order
+        order = comparePropsOrder(propertiesNames, propA, propB);
+
+        // Continue to next comparison is order is correct
+        if (order.correct === true) {
+          continue;
+        }
+
+        // Store an error if the order is incorrect
+        storeError({
+          node: properties[i],
+          index: order.indexA
+        }, {
+          node: properties[k],
+          index: order.indexB
+        });
+      }
+    }
+
+  }
+
+  return {
+    'Program:exit': function() {
+      var list = componentList.getList();
+
+      for (var component in list) {
+        if (!list.hasOwnProperty(component) || !mustBeValidated(list[component])) {
+          continue;
+        }
+        var properties = getComponentProperties(list[component].node);
+        checkPropsOrder(properties);
+      }
+
+      reportErrors();
+    },
+
+    ReturnStatement: function(node) {
+      if (!componentUtil.isReactComponent(context, node)) {
+        return;
+      }
+      componentList.set(context, node, {
+        isReactComponent: true
+      });
+    }
+  };
+
+};


Test patch:
diff --git a/tests/lib/rules/sort-comp.js b/tests/lib/rules/sort-comp.js
new file mode 100644
index 00000000..138010d7
--- /dev/null
+++ b/tests/lib/rules/sort-comp.js
@@ -0,0 +1,234 @@
+/**
+ * @fileoverview Enforce component methods order
+ * @author Yannick Croissant
+ */
+'use strict';
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+var eslint = require('eslint').linter;
+var ESLintTester = require('eslint-tester');
+
+require('babel-eslint');
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+var eslintTester = new ESLintTester(eslint);
+eslintTester.addRuleTest('lib/rules/sort-comp', {
+
+  valid: [{
+    // Must validate a full class
+    code: [
+      'var Hello = React.createClass({',
+      '  displayName : \'\',',
+      '  propTypes: {},',
+      '  mixins: [],',
+      '  statics: {},',
+      '  getDefaultProps: function() {},',
+      '  getInitialState: function() {},',
+      '  componentWillMount: function() {},',
+      '  componentDidMount: function() {},',
+      '  componentWillReceiveProps: function() {},',
+      '  shouldComponentUpdate: function() {},',
+      '  componentWillUpdate: function() {},',
+      '  componentDidUpdate: function() {},',
+      '  componentWillUnmount: function() {},',
+      '  render: function() {',
+      '    return <div>Hello</div>;',
+      '  }',
+      '});'
+    ].join('\n'),
+    ecmaFeatures: {
+      jsx: true
+    }
+  }, {
+    // Must validate a class with missing groups
+    code: [
+      'var Hello = React.createClass({',
+      '  render: function() {',
+      '    return <div>Hello</div>;',
+      '  }',
+      '});'
+    ].join('\n'),
+    ecmaFeatures: {
+      jsx: true
+    }
+  }, {
+    // Must but a custom method in 'everything-else'
+    code: [
+      'var Hello = React.createClass({',
+      '  onClick: function() {},',
+      '  render: function() {',
+      '    return <button onClick={this.onClick}>Hello</button>;',
+      '  }',
+      '});'
+    ].join('\n'),
+    ecmaFeatures: {
+      jsx: true
+    }
+  }, {
+    // Must allow us to re-order the groups
+    code: [
+      'var Hello = React.createClass({',
+      '  displayName : \'Hello\',',
+      '  render: function() {',
+      '    return <button onClick={this.onClick}>Hello</button>;',
+      '  },',
+      '  onClick: function() {}',
+      '});'
+    ].join('\n'),
+    args: [1, {
+      order: [
+        'lifecycle',
+        'render',
+        'everything-else'
+      ]
+    }],
+    ecmaFeatures: {
+      jsx: true
+    }
+  }, {
+    // Must allow us to create a RegExp-based group
+    code: [
+      'class Hello extends React.Component {',
+      '  customHandler() {}',
+      '  render() {',
+      '    return <div>Hello</div>;',
+      '  }',
+      '  onClick() {}',
+      '}'
+    ].join('\n'),
+    args: [1, {
+      order: [
+        'lifecycle',
+        'everything-else',
+        'render',
+        '/on.*/'
+      ]
+    }],
+    ecmaFeatures: {
+      classes: true,
+      jsx: true
+    }
+  }, {
+    // Must allow us to create a named group
+    code: [
+      'class Hello extends React.Component {',
+      '  customHandler() {}',
+      '  render() {',
+      '    return <div>Hello</div>;',
+      '  }',
+      '  onClick() {}',
+      '}'
+    ].join('\n'),
+    args: [1, {
+      order: [
+        'lifecycle',
+        'everything-else',
+        'render',
+        'customGroup'
+      ],
+      groups: {
+        customGroup: [
+          '/on.*/'
+        ]
+      }
+    }],
+    ecmaFeatures: {
+      classes: true,
+      jsx: true
+    }
+  }, {
+    // Must allow a method to be in different places if it's matches multiple patterns
+    code: [
+      'class Hello extends React.Component {',
+      '  render() {',
+      '    return <div>Hello</div>;',
+      '  }',
+      '  onClick() {}',
+      '}'
+    ].join('\n'),
+    args: [1, {
+      order: [
+        '/on.*/',
+        'render',
+        '/.*Click/'
+      ]
+    }],
+    ecmaFeatures: {
+      classes: true,
+      jsx: true
+    }
+  }],
+
+  invalid: [{
+    // Must force a lifecycle method to be placed before render
+    code: [
+      'var Hello = React.createClass({',
+      '  render: function() {',
+      '    return <div>Hello</div>;',
+      '  },',
+      '  displayName : \'Hello\',',
+      '});'
+    ].join('\n'),
+    ecmaFeatures: {
+      jsx: true
+    },
+    errors: [{message: 'render must be placed after displayName'}]
+  }, {
+    // Must force a custom method to be placed before render
+    code: [
+      'var Hello = React.createClass({',
+      '  render: function() {',
+      '    return <div>Hello</div>;',
+      '  },',
+      '  onClick: function() {},',
+      '});'
+    ].join('\n'),
+    ecmaFeatures: {
+      jsx: true
+    },
+    errors: [{message: 'render must be placed after onClick'}]
+  }, {
+    // Must force a custom method to be placed after render if no 'everything-else' group is specified
+    code: [
+      'var Hello = React.createClass({',
+      '  displayName: \'Hello\',',
+      '  onClick: function() {},',
+      '  render: function() {',
+      '    return <button onClick={this.onClick}>Hello</button>;',
+      '  }',
+      '});'
+    ].join('\n'),
+    args: [1, {
+      order: [
+        'lifecycle',
+        'render'
+      ]
+    }],
+    ecmaFeatures: {
+      jsx: true
+    },
+    errors: [{message: 'onClick must be placed after render'}]
+  }, {
+    // Must validate static properties
+    code: [
+      'class Hello extends React.Component {',
+      '  render() {',
+      '    return <div></div>',
+      '  }',
+      '  static displayName = \'Hello\'',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    ecmaFeatures: {
+      classes: true,
+      jsx: true
+    },
+    errors: [{message: 'render must be placed after displayName'}]
+  }]
+});