Repository: abuiles/ember-watson
Pull request #87 (2015-10-05 16:39:59)

Add a command to replace needs with injection
Relates to https://github.com/abuiles/ember-watson/issues/85

Replace:
```javascript
needs: ['foo'],
fooModel: Em.computed.alias('controllers.foo.model')
```

With
```
fooController: Em.inject.controller('foo'),
fooModel: Em.computed.alias('fooController.model')
```

I had a need for this in our internal project so I took it on today. I'm completely new to the recast AST API so I may be using it sub-optimally. Also some of the decisions I've made with regard to renaming might only make sense in the context of our project.

I was unsure how to approach the template aspect of this so I've ignored it.

I'm happy to change anything that seems incorrect.

abuiles: Thanks! I'll review this latter today and get it in :) 
abuiles: @paddyobrien  

> Also some of the decisions I've made with regard to renaming might only make sense in the context of our project.

Could you elaborate more about those ones? 
GavinJoyce: @abuiles I think he's referring to both the addition of the `Controller` suffix (to avoid naming collisions) and support for handling duplicate leaf route names:

```javascript
import Em from 'ember';

export default Ember.Controller.extend({
  fooBarController: Em.inject.controller('foo/bar'),
  bazBarController: Em.inject.controller('baz/bar')
});
```

![image](https://cloud.githubusercontent.com/assets/2526/10291604/a6f19d5c-6ba2-11e5-9328-30a1b83533ce.png)
paddyobrien: Yep exactly that.

On Monday, October 5, 2015, Gavin Joyce <notifications@github.com> wrote:

> @abuiles <https://github.com/abuiles> I think he's referring to both the
> addition of the Controller suffix (to avoid naming collisions) and
> support for handling duplicate leaf route names:
>
> import Em from 'ember';
> export default Ember.Controller.extend({
>   fooBarController: Em.inject.controller('foo/bar'),
>   bazBarController: Em.inject.controller('baz/bar')
> });
>
> [image: image]
> <https://cloud.githubusercontent.com/assets/2526/10291604/a6f19d5c-6ba2-11e5-9328-30a1b83533ce.png>
>
> —
> Reply to this email directly or view it on GitHub
> <https://github.com/abuiles/ember-watson/pull/87#issuecomment-145649008>.
>

abuiles: @paddyobrien finally went through this and it looks great, I didn't realized but I've been also using `somethingController: Ember.inject.controller('something')` so let's ship it like it.

Can you do me a last favor, can you add the command to the readme with a brief description, I'll release after that, :+1: 

Patch:
diff --git a/README.md b/README.md
index 3108636..d1045dc 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,10 @@ Convert (qunit or mocha flavored) acceptance tests to utilize the `destroyApp`
 helper [introduced](https://github.com/ember-cli/ember-cli/pull/4772) in
 Ember CLI 1.13.9.
 
+#### `ember watson:replace-needs-with-injection <path>`
+
+Convert `needs` declarations the individual properties using the new `Ember.inject.controller()` feature. Also convert any uses of the `controllers` hash to use the newly defined properties.
+
 ### Specifying a file or path.
 
 You can run any of the commands passing as argument the path, file or
diff --git a/lib/cli.js b/lib/cli.js
index c191d4e..37ca4fa 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -73,6 +73,14 @@ program
     watson.transformTestToUseDestroyApp(path);
   });
 
+program
+  .command('replace-needs-with-injection [path]')
+  .description('Replace needs with controller injection.')
+  .action(function(path) {
+    path = path || 'app';
+    watson.replaceNeedsWithInjection(path);
+  });
+
 module.exports = function init(args) {
   program.parse(args);
 };
diff --git a/lib/commands/index.js b/lib/commands/index.js
index e01e9ee..494995f 100644
--- a/lib/commands/index.js
+++ b/lib/commands/index.js
@@ -8,5 +8,6 @@ module.exports = {
   'watson:methodify': require('./methodify'),
   'watson:convert-resource-router-mapping': require('./convert-resource-router-mapping'),
   'watson:find-overloaded-cps': require('./find-overloaded-cps'),
-  'watson:use-destroy-app-helper': require('./use-destroy-app-helper')
+  'watson:use-destroy-app-helper': require('./use-destroy-app-helper'),
+  'watson:replace-needs-with-injection': require('./replace-needs-with-injection')
 };
diff --git a/lib/commands/replace-needs-with-injection.js b/lib/commands/replace-needs-with-injection.js
new file mode 100644
index 0000000..dafc63e
--- /dev/null
+++ b/lib/commands/replace-needs-with-injection.js
@@ -0,0 +1,17 @@
+'use strict';
+
+var Watson = require('../../index');
+var watson = new Watson();
+
+module.exports = {
+  name: 'watson:replace-needs-with-injection',
+  description: 'Replace needs with controller injection.',
+  works: 'insideProject',
+  anonymousOptions: [
+    '<path>'
+  ],
+  run: function(commandOptions, rawArgs) {
+    var path = rawArgs[0] || 'app/controllers';
+    watson.replaceNeedsWithInjection(path);
+  }
+};
diff --git a/lib/formulas/replace-needs-with-injection.js b/lib/formulas/replace-needs-with-injection.js
new file mode 100644
index 0000000..e1036e2
--- /dev/null
+++ b/lib/formulas/replace-needs-with-injection.js
@@ -0,0 +1,154 @@
+var parseAst    = require('../helpers/parse-ast');
+var recast      = require('recast');
+var b    = recast.types.builders;
+var isImportFor = require('./helpers/is-import-for');
+var addDefaultImport = require('./helpers/add-default-import');
+
+module.exports = function transform(source) {
+  var ast = parseAst(source);
+  var needs, emberImport;
+  var uses = [];
+  recast.visit(ast, {
+    visitProperty: function(path) {
+      if(isNeedsDeclaration(path)) {
+        needs = path;
+        return false;
+      }
+      this.traverse(path);
+    },
+    visitLiteral: function(path) {
+      if (isNeedsUsage(path)) {
+        uses.push(path);
+      }
+      this.traverse(path);
+    },
+    visitImportDeclaration: function(path) {
+      if (isImportFor('ember', path.node)) {
+        emberImport = path;
+      }
+      this.traverse(path);
+    },
+  });
+
+  transformNeeds(ast, needs, uses, emberImport);
+  return recast.print(ast, { tabWidth: 2, quote: 'single' }).code;
+};
+
+function transformNeeds(ast, needs, uses, emberImport) {
+  if (needs) {
+    // What is Ember imported as?
+    var emberIdentifier = getEmberImportName(ast, emberImport);
+    // Get a normal array of needs names
+    var needsArray = normaliseNeeds(needs);
+    // Generate a map from controller path to new property names
+    var newKeysMap = generateNewKeysMap(needsArray);
+    // Update the paths
+    replaceNeedsProperty(emberIdentifier, needs, newKeysMap);
+    if (uses.length > 0) {
+      transformUses(uses, newKeysMap);
+    }
+  }
+}
+
+var replaceNeedsProperty = function(emberIdentifier, path, newKeyMap) {
+  var replacements = [];
+  if (path.value.value.elements) {
+    path.value.value.elements.forEach(function(element) {
+      path.parentPath.unshift(buildInjectionExpression(emberIdentifier, element.value, newKeyMap));
+    });
+    path.replace();
+  } else {
+    path.replace(buildInjectionExpression(emberIdentifier, path.value.value.value, newKeyMap));
+  }
+};
+
+var transformUses = function(paths, newKeyMap) {
+  paths.forEach(function(path) {
+    for(var prop in newKeyMap) {
+      var regex = new RegExp('controllers\\.' + prop + '(\\.|$)');
+      var match = path.value.value.match(regex);
+      if (match) {
+        var newLiteral = path.value.value.replace(regex, newKeyMap[prop] + match[1]);
+        path.replace(b.literal(newLiteral));
+      }
+    }
+  });
+};
+
+function capitalizeFirstLetter(string) {
+  return string.charAt(0).toUpperCase() + string.slice(1);
+}
+
+function invertMap(map) {
+  var invertedMap = {};
+  for(var prop in map) {
+    if(map.hasOwnProperty(prop)) {
+      invertedMap[map[prop]] = prop;
+    }
+  }
+  return invertedMap;
+}
+
+var generateNewKeysMap = function(needsArray) {
+  var newKeys = {};
+  needsArray.forEach(function(needsNode) {
+    var needsValue = needsNode.value;
+    var needsParts = needsValue.split('/');
+    var key = 'Controller';
+    do {
+      key = capitalizeFirstLetter(key);
+      key = needsParts.pop().replace(/-/g, '') + key;
+    } while(newKeys[key] !== undefined);
+    newKeys[key] = needsValue;
+  });
+  return invertMap(newKeys);
+};
+
+function isNeedsUsage(path) {
+  return path &&
+    path.value &&
+    typeof path.value.value === 'string' &&
+    path.value.value.indexOf('controllers.') === 0;
+}
+
+function isNeedsDeclaration(path) {
+  return path.value.key.name === 'needs';
+}
+
+function normaliseNeeds(needs) {
+  var needsArray = needs.value.value.elements;
+  if (needsArray === undefined) {
+    needsArray = [needs.value.value];
+  }
+  return needsArray;
+}
+
+function getEmberImportName(ast, emberImport) {
+  if (!emberImport) {
+    addDefaultImport(ast, 'ember', 'Ember');
+    emberIdentifier = 'Ember';
+  } else {
+    emberIdentifier = emberImport.value.specifiers[0].local.name;
+  }
+  return emberIdentifier;
+}
+
+var buildInjectionExpression = function(emberIdentifier, controllerPath, newKeyMap) {
+  var name = newKeyMap[controllerPath];
+  return b.property(
+    'init',
+    b.identifier(name),
+    b.callExpression(
+      b.memberExpression(
+        b.identifier(emberIdentifier),
+        b.memberExpression(
+          b.identifier("inject"),
+          b.identifier("controller"),
+          false
+        ),
+        false
+      ),
+      [b.literal(controllerPath)]
+    )
+  );
+};
\ No newline at end of file
diff --git a/lib/watson.js b/lib/watson.js
index 6906a8c..6a69eff 100644
--- a/lib/watson.js
+++ b/lib/watson.js
@@ -13,6 +13,7 @@ var transformResourceRouterMapping = require('./formulas/resource-router-mapping
 var transformMethodify = require('./formulas/methodify');
 var FindOverloadedCPs = require('./formulas/find-overloaded-cps');
 var transformDestroyApp = require('./formulas/destroy-app-transform');
+var replaceNeedsWithInjection = require('./formulas/replace-needs-with-injection');
 
 module.exports = EmberWatson;
 
@@ -67,6 +68,13 @@ EmberWatson.prototype.transformResourceRouterMapping = function(routerPath) {
 
 EmberWatson.prototype._transformDestroyApp = transformDestroyApp;
 
+EmberWatson.prototype.replaceNeedsWithInjection = function(path) {
+  var files = findFiles(path, '.js');
+  transform(files, this._replaceNeedsWithInjection);
+};
+
+EmberWatson.prototype._replaceNeedsWithInjection = replaceNeedsWithInjection;
+
 EmberWatson.prototype.transformTestToUseDestroyApp = function(rootPath) {
   if (!existsSync('tests/helpers/destroy-app.js')) {
     console.log(


Test patch:
diff --git a/tests/fixtures/replace-needs-with-injection/array-result.js b/tests/fixtures/replace-needs-with-injection/array-result.js
new file mode 100644
index 0000000..1daf650
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/array-result.js
@@ -0,0 +1,6 @@
+import Em from 'ember';
+
+export default Ember.Controller.extend({
+  barController: Em.inject.controller('bar'),
+  fooController: Em.inject.controller('foo')
+});
diff --git a/tests/fixtures/replace-needs-with-injection/array.js b/tests/fixtures/replace-needs-with-injection/array.js
new file mode 100644
index 0000000..59e5863
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/array.js
@@ -0,0 +1,5 @@
+import Em from 'ember';
+
+export default Ember.Controller.extend({
+  needs: ['foo', 'bar']
+});
diff --git a/tests/fixtures/replace-needs-with-injection/duplicate-names-result.js b/tests/fixtures/replace-needs-with-injection/duplicate-names-result.js
new file mode 100644
index 0000000..0646075
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/duplicate-names-result.js
@@ -0,0 +1,6 @@
+import Em from 'ember';
+
+export default Ember.Controller.extend({
+  fooBarController: Em.inject.controller('foo/bar'),
+  bazBarController: Em.inject.controller('baz/bar')
+});
diff --git a/tests/fixtures/replace-needs-with-injection/duplicate-names.js b/tests/fixtures/replace-needs-with-injection/duplicate-names.js
new file mode 100644
index 0000000..a2386bd
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/duplicate-names.js
@@ -0,0 +1,8 @@
+import Em from 'ember';
+
+export default Ember.Controller.extend({
+  needs: [
+    'foo/bar',
+    'baz/bar'
+  ]
+});
diff --git a/tests/fixtures/replace-needs-with-injection/no-ember-import-result.js b/tests/fixtures/replace-needs-with-injection/no-ember-import-result.js
new file mode 100644
index 0000000..13b8b21
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/no-ember-import-result.js
@@ -0,0 +1,6 @@
+import Ember from 'ember';
+import SomeBaseController from 'somewhere';
+
+export default SomeBaseController.extend({
+  fooController: Ember.inject.controller('foo')
+});
diff --git a/tests/fixtures/replace-needs-with-injection/no-ember-import.js b/tests/fixtures/replace-needs-with-injection/no-ember-import.js
new file mode 100644
index 0000000..ab58d88
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/no-ember-import.js
@@ -0,0 +1,5 @@
+import SomeBaseController from 'somewhere';
+
+export default SomeBaseController.extend({
+  needs: ['foo']
+});
diff --git a/tests/fixtures/replace-needs-with-injection/single-result.js b/tests/fixtures/replace-needs-with-injection/single-result.js
new file mode 100644
index 0000000..73c1306
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/single-result.js
@@ -0,0 +1,5 @@
+import Ember from 'ember';
+
+export default Ember.Controller.extend({
+  fooController: Ember.inject.controller('foo')
+});
diff --git a/tests/fixtures/replace-needs-with-injection/single.js b/tests/fixtures/replace-needs-with-injection/single.js
new file mode 100644
index 0000000..08a110a
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/single.js
@@ -0,0 +1,5 @@
+import Ember from 'ember';
+
+export default Ember.Controller.extend({
+  needs: 'foo'
+});
diff --git a/tests/fixtures/replace-needs-with-injection/uses-result.js b/tests/fixtures/replace-needs-with-injection/uses-result.js
new file mode 100644
index 0000000..424fa1f
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/uses-result.js
@@ -0,0 +1,7 @@
+import Em from 'ember';
+
+export default Ember.Controller.extend({
+  fooController: Em.inject.controller('foo'),
+  fooModel: Em.computed.alias('fooController.model'),
+  computedFoo: Em.computed('fooController', function() {})
+});
diff --git a/tests/fixtures/replace-needs-with-injection/uses.js b/tests/fixtures/replace-needs-with-injection/uses.js
new file mode 100644
index 0000000..e0ec95e
--- /dev/null
+++ b/tests/fixtures/replace-needs-with-injection/uses.js
@@ -0,0 +1,7 @@
+import Em from 'ember';
+
+export default Ember.Controller.extend({
+  needs: ['foo'],
+  fooModel: Em.computed.alias('controllers.foo.model'),
+  computedFoo: Em.computed('controllers.foo', function() {})
+});
diff --git a/tests/replace-needs-with-injection-test.js b/tests/replace-needs-with-injection-test.js
new file mode 100644
index 0000000..362755d
--- /dev/null
+++ b/tests/replace-needs-with-injection-test.js
@@ -0,0 +1,41 @@
+var Watson = require('../index.js');
+var fs = require('fs');
+var astEquality = require('./helpers/ast-equality');
+
+describe('replacing deprecated needs with controller injection', function() {
+
+  it('replaces single needs', function() {
+    var source = fs.readFileSync('./tests/fixtures/replace-needs-with-injection/single.js');
+    var watson = new Watson();
+    var newSource = watson._replaceNeedsWithInjection(source);
+    astEquality(newSource, fs.readFileSync('./tests/fixtures/replace-needs-with-injection/single-result.js'));
+  });
+
+  it('replaces needs arrays', function() {
+    var source = fs.readFileSync('./tests/fixtures/replace-needs-with-injection/array.js');
+    var watson = new Watson();
+    var newSource = watson._replaceNeedsWithInjection(source);
+    astEquality(newSource, fs.readFileSync('./tests/fixtures/replace-needs-with-injection/array-result.js'));
+  });
+
+  it('dedupes controller names', function() {
+    var source = fs.readFileSync('./tests/fixtures/replace-needs-with-injection/array.js');
+    var watson = new Watson();
+    var newSource = watson._replaceNeedsWithInjection(source);
+    astEquality(newSource, fs.readFileSync('./tests/fixtures/replace-needs-with-injection/array-result.js'));
+  });
+
+  it('replaces uses', function() {
+    var source = fs.readFileSync('./tests/fixtures/replace-needs-with-injection/uses.js');
+    var watson = new Watson();
+    var newSource = watson._replaceNeedsWithInjection(source);
+    astEquality(newSource, fs.readFileSync('./tests/fixtures/replace-needs-with-injection/uses-result.js'));
+  });
+
+  it('adds an import if needed', function() {
+    var source = fs.readFileSync('./tests/fixtures/replace-needs-with-injection/no-ember-import.js');
+    var watson = new Watson();
+    var newSource = watson._replaceNeedsWithInjection(source);
+    astEquality(newSource, fs.readFileSync('./tests/fixtures/replace-needs-with-injection/no-ember-import-result.js'));
+  });
+});
\ No newline at end of file