Repository: rwjblue/ember-template-lint (js)

Issue:
support plugins extending other plugins
The main use case is to have a plugin extend base `recommended` so you don't have to do `extends: ['recommended', 'plugin1:recommended'],` yourself. I tried inside of a plugin:

```js
configurations: {
  recommended: {
    extends: 'recommended',
    rules: {
    }
  }
}
```

but it didn't work.

Pull request description:
allow chaining of plugins and extends
Closes #321

Patch:
diff --git a/lib/get-config.js b/lib/get-config.js
index 4c428b7..8165bf5 100644
--- a/lib/get-config.js
+++ b/lib/get-config.js
@@ -31,25 +31,49 @@ function resolveConfigPath(configPath, pluginPath) {
 
 }
 
-function ensureRootProperties(config, source, options) {
-  let logger = options.console || console;
+function forEachPluginConfiguration(plugins, callback) {
+  if (!plugins) {
+    return;
+  }
 
-  config.rules = assign({}, source.rules || {});
-  config.pending = (source.pending || []).slice();
-  config.ignore = (source.ignore || []).slice();
-  config.plugins = (source.plugins || []).slice();
+  for (let pluginName in plugins) {
+
+    let pluginConfigurations = plugins[pluginName].configurations;
+    if (!pluginConfigurations) {
+      continue;
+    }
+
+    for (let configurationName in pluginConfigurations) {
+      let configuration = pluginConfigurations[configurationName];
+
+      callback(configuration, configurationName, pluginName);
+    }
+
+  }
+}
+
+function normalizeExtends(config, options) {
+  let logger = options.console || console;
 
   let extendedList = [];
-  if (source.extends) {
-    if (typeof source.extends === 'string') {
-      extendedList = [source.extends];
-    } else if (Array.isArray(source.extends)) {
-      extendedList = source.extends.slice();
+  if (config.extends) {
+    if (typeof config.extends === 'string') {
+      extendedList = [config.extends];
+    } else if (Array.isArray(config.extends)) {
+      extendedList = config.extends.slice();
     } else {
       logger.log(chalk.yellow('config.extends should be string or array '));
     }
   }
-  config.extends = extendedList;
+  return extendedList;
+}
+
+function ensureRootProperties(config, source) {
+  config.rules = assign({}, source.rules || {});
+  config.pending = (source.pending || []).slice();
+  config.ignore = (source.ignore || []).slice();
+  config.plugins = (source.plugins || []).slice();
+  config.extends = source.extends;
 }
 
 function migrateRulesFromRoot(config, source, options) {
@@ -76,54 +100,74 @@ function migrateRulesFromRoot(config, source, options) {
   }
 }
 
-function processPlugins(config, options) {
+function processPlugins(config, options, checkForCircularReference) {
 
   let logger = options.console || console;
 
   let configPlugins = config.plugins;
   let pluginsHash = {};
 
-  for (let i = 0; i < configPlugins.length; i++) {
+  if (configPlugins) {
 
-    let plugin = configPlugins[i];
-    let pluginName;
+    for (let i = 0; i < configPlugins.length; i++) {
+
+      let plugin = configPlugins[i];
+      let pluginName;
+
+      if (typeof plugin === 'string') {
+        pluginName = plugin;
+        plugin = resolveConfigPath(options.configPath, pluginName);
+      }
 
-    if (typeof plugin === 'string') {
-      pluginName = plugin;
-      plugin = resolveConfigPath(options.configPath, pluginName);
-    }
 
+      let errorMessage;
+      if (typeof plugin === 'object') {
 
-    let errorMessage;
-    if (typeof plugin === 'object') {
+        if (plugin.name) {
+          if (!checkForCircularReference || !checkForCircularReference[plugin.name]) {
+            pluginsHash[plugin.name] = plugin;
+          }
+        } else if (pluginName) {
+          errorMessage = `Plugin (${pluginName}) has not defined the plugin \`name\` property`;
+        } else {
+          errorMessage = 'Inline plugin object has not defined the plugin `name` property';
+        }
 
-      if (plugin.name) {
-        pluginsHash[plugin.name] = plugin;
       } else if (pluginName) {
-        errorMessage = `Plugin (${pluginName}) has not defined the plugin \`name\` property`;
+        errorMessage = `Plugin (${pluginName}) did not return a plain object`;
       } else {
-        errorMessage = 'Inline plugin object has not defined the plugin `name` property';
+        errorMessage = 'Inline plugin is not a plain object';
       }
 
-    } else if (pluginName) {
-      errorMessage = `Plugin (${pluginName}) did not return a plain object`;
-    } else {
-      errorMessage = 'Inline plugin is not a plain object';
-    }
+      if (errorMessage) {
+        logger.log(chalk.yellow(errorMessage));
+      }
 
-    if (errorMessage) {
-      logger.log(chalk.yellow(errorMessage));
     }
 
   }
 
+  forEachPluginConfiguration(pluginsHash, configuration => {
+
+    // process plugins recursively
+    assign(pluginsHash, processPlugins(configuration, options, pluginsHash));
+
+  });
+
   config.plugins = pluginsHash;
+
+  return pluginsHash;
 }
 
 function processLoadedRules(config) {
 
-  //load all the default rules in `ember-template-lint`
-  let loadedRules = assign({}, defaultRules);
+  let loadedRules;
+  if (config.loadedRules) {
+    loadedRules = config.loadedRules;
+  } else {
+    //load all the default rules in `ember-template-lint`
+    loadedRules = assign({}, defaultRules);
+  }
 
   //load plugin rules
   for (let name in config.plugins) {
@@ -135,31 +179,42 @@ function processLoadedRules(config) {
 
   }
 
+  forEachPluginConfiguration(config.plugins, configuration => {
+
+    // process plugins recursively
+    processLoadedRules({
+      plugins: configuration.plugins,
+      loadedRules
+    });
+
+  });
+
   config.loadedRules = loadedRules;
 
 }
 
 function processLoadedConfigurations(config) {
 
-  //load all the default configurations in `ember-template-lint`
-  let loadedConfigurations = assign({}, defaultConfigurations);
-
-  //load plugin configurations
-  for (let pluginName in config.plugins) {
-
-    let pluginConfigurations = config.plugins[pluginName].configurations;
-    if (pluginConfigurations) {
+  let loadedConfigurations;
+  if (config.loadedConfigurations) {
+    loadedConfigurations = config.loadedConfigurations;
+  } else {
+    //load all the default configurations in `ember-template-lint`
+    loadedConfigurations = assign({}, defaultConfigurations);
+  }
 
-      for (let configurationName in pluginConfigurations) {
+  forEachPluginConfiguration(config.plugins, (configuration, configurationName, pluginName) => {
 
+    let name = `${pluginName}:${configurationName}`;
+    loadedConfigurations[name] = configuration;
 
-        let name = `${pluginName}:${configurationName}`;
-        loadedConfigurations[name] = pluginConfigurations[configurationName];
+    // load plugins recursively
+    processLoadedConfigurations({
+      plugins: configuration.plugins,
+      loadedConfigurations
+    });
 
-      }
-    }
-
-  }
+  });
 
   config.loadedConfigurations = loadedConfigurations;
 
@@ -169,32 +224,48 @@ function processExtends(config, options) {
 
   let logger = options.console || console;
 
-  let extendedList = config.extends;
+  let extendedList = normalizeExtends(config, options);
 
-  for (let i = 0; i < extendedList.length; i++) {
+  if (extendedList) {
 
-    let extendName = extendedList[i];
+    for (let i = 0; i < extendedList.length; i++) {
 
-    let configuration = config.loadedConfigurations[extendName];
-    if (configuration) {
+      let extendName = extendedList[i];
 
-      if (configuration.rules) {
+      let configuration = config.loadedConfigurations[extendName];
+      if (configuration) {
 
-        config.rules = assign({}, configuration.rules, config.rules);
+        // ignore loops
+        if (!configuration.loadedConfigurations) {
 
-      } else {
+          configuration.loadedConfigurations = config.loadedConfigurations;
 
-        logger.log(chalk.yellow(`Missing rules for extends: ${extendName}`));
+          // continue chaining `extends` from plugins until done
+          processExtends(configuration, options);
 
-      }
+          delete configuration.loadedConfigurations;
 
-    } else {
+          if (configuration.rules) {
+
+            config.rules = assign({}, configuration.rules, config.rules);
+
+          } else {
 
-      logger.log(chalk.yellow(`Cannot find configuration for extends: ${extendName}`));
+            logger.log(chalk.yellow(`Missing rules for extends: ${extendName}`));
+
+          }
+
+        }
+
+      } else {
+
+        logger.log(chalk.yellow(`Cannot find configuration for extends: ${extendName}`));
+
+      }
 
+      delete config.extends;
     }
 
-    delete config.extends;
   }
 
 }
@@ -228,7 +299,7 @@ module.exports = function(options) {
     // don't mutate a `require`d object, you'll have a bad time
     config = {};
 
-    ensureRootProperties(config, source, options);
+    ensureRootProperties(config, source);
     migrateRulesFromRoot(config, source, options);
 
     processPlugins(config, options);


Test patch:
diff --git a/test/unit/get-config-test.js b/test/unit/get-config-test.js
index 1e0caec..12ec256 100644
--- a/test/unit/get-config-test.js
+++ b/test/unit/get-config-test.js
@@ -202,6 +202,128 @@ describe('get-config', function() {
     expect(actual.loadedRules['foo-bar']).to.equal('plugin-function-placeholder');
   });
 
+  it('can chain extends and load rules across chained plugins', function() {
+    let message;
+    let actual = getConfig({
+      console: { log(_message) {
+        message = _message;
+      }},
+
+      config: {
+        extends: 'plugin1:recommended',
+
+        plugins: [{
+          name: 'plugin1',
+
+          configurations: {
+            recommended: {
+              extends: 'plugin2:recommended',
+
+              plugins: [{
+                name: 'plugin2',
+
+                rules: {
+                  'foo-bar': true
+                },
+
+                configurations: {
+                  recommended: {
+                    extends: 'recommended',
+
+                    rules: {
+                      'foo-bar': true
+                    }
+                  }
+                }
+              }]
+            }
+          }
+        }]
+      }
+
+    });
+
+    expect(message).to.be.not.ok;
+    expect(actual.rules['foo-bar']).to.equal(true);
+    expect(actual.rules['block-indentation']).to.equal(2);
+  });
+
+  it('handles circular reference in config', function() {
+    let message;
+
+    let config = {
+      extends: 'plugin1:recommended',
+
+      rules: {
+        'foo-bar': true
+      },
+
+      plugins: [{
+        name: 'plugin1',
+
+        rules: {
+          'foo-bar': true
+        },
+
+        configurations: {}
+      }]
+    };
+
+    config.plugins[0].configurations.recommended = config;
+
+    let actual = getConfig({
+      console: { log(_message) {
+        message = _message;
+      }},
+
+      config
+
+    });
+
+    expect(message).to.be.not.ok;
+    expect(actual.rules['foo-bar']).to.equal(true);
+  });
+
+  it('handles circular reference in plugin', function() {
+    let message;
+
+    let plugin = {
+      name: 'plugin1',
+
+      rules: {
+        'foo-bar': true
+      },
+
+      configurations: {
+        recommended: {
+          extends: 'plugin1:recommended',
+
+          rules: {
+            'foo-bar': true
+          },
+        }
+      }
+    };
+
+    plugin.configurations.recommended.plugins = [plugin];
+
+    let actual = getConfig({
+      console: { log(_message) {
+        message = _message;
+      }},
+
+      config: {
+        extends: 'plugin1:recommended',
+
+        plugins: [plugin]
+      }
+
+    });
+
+    expect(message).to.be.not.ok;
+    expect(actual.rules['foo-bar']).to.equal(true);
+  });
+
   it('getting config is idempotent', function() {
     let firstMessage;
     let secondMessage;