sbt/sbt-jshint-plugin — pull request #6 by huntc (2014-02-12 05:16:44+00:00)
Title: Simplify the authoring of an sbt-web plugin

The issue raised here is done so in order to prototype how an sbt-web's plugin authoring can be simplified.

At present a plugin has three main concerns:

1. sbt presentation
2. a JVM library of functions to invoke the tool in question e.g. JSHint
3. a JavaScript (shell.js) that targets the Node API, receives params on the files to work with and the tooling options to use, and then logs the results of the operation.

The idea is to generalise (1) so that there is a minimal amount of sbt code, eliminate (2) entirely and have (3) perform the transformation of its results into a form that can be easily consumed by (1).

shell.js
---------
Working backwards, here is what a shell.js might look like:

```javascript
/*global process, require */

/*
* Lint a number of files.
*
* Arguments:
* 0 - name given to the command invoking the script (unused)
* 1 - filepath of this script (unused)
* 2 - array of file paths to the files to lint
* 3 - jshint options as a Json object
*
* Json array tuples are sent to stdout for each file in error (if any). Each tuple is an array where the
* element 0 corresponds to the file path of the file linted, and element 1 is JSHINT.errors.
*/

(function () {

"use strict";

var args = process.argv;
var console = require("console");
var fs = require("fs");
var jshint = require("jshint");

var SOURCE_FILE_PATHS_ARG = 2;
var OPTIONS_ARG = 3;

var options = JSON.parse(args[OPTIONS_ARG]);

var sourceFilePaths = JSON.parse(args[SOURCE_FILE_PATHS_ARG]);
var sourceFilesToProcess = sourceFilePaths.length;
var results = [];
var problems = [];
sourceFilePaths.forEach(function (sourceFilePath) {
fs.readFile(sourceFilePath, "utf8", function (e, source) {
if (e) {
console.error("Error while trying to read " + source, e);
} else {
var status = jshint.JSHINT(source, options);
results.push({
source: sourceFilePath,
result: status ? null : {filesRead: [sourceFilePath], filesWritten: []}
});
jshint.JSHINT.errors.forEach(function(e) {
problems.push({
message: e.reason,
severity: e.id.substring(1, e.id.length - 1),
lineNumber: e.line,
characterOffset: e.character - 1,
lineContent: e.evidence,
source: sourceFilePath
});
});
}
if (--sourceFilesToProcess === 0) {
console.log(JSON.stringify({results: results, problems: problems}));
}
});
});
}());
```

The output is an array of problems and results e.g.:

```json
{
"problems": [
{
"characterOffset": 5,
"lineContent": "a = 1",
"lineNumber": 1,
"message": "Missing semicolon.",
"severity": "error",
"source": "src/main/assets/js/a.js"
}
],
"results": [
{
"result": {
"filesRead": [
"src/main/assets/js/a.js"
],
"filesWritten": []
},
"source": "src/main/assets/js/a.js"
}
]
}
```

This structure can be transformed directly into sbt-web's incremental execution functions.

sbt
---
The main functionality here is to declare keys as per regular sbt and then simplify the binding of them such that the entire plugin becomes:

```scala
/**
* The sbt plugin plumbing around the JSHint library.
*/
object JSHintPlugin extends JsTaskPlugin {

object JshintKeys {

val jshint = TaskKey[Unit]("jshint", "Perform JavaScript linting.")

val jshintOptions = TaskKey[String]("jshint-options", "An array of jshint options to pass to the linter. This options are found via jshint-resolved-config. If there is no config then the options will be specified such that the JSHint defaults are used.")

val config = SettingKey[Option[File]]("jshint-config", "The location of a JSHint configuration file.")
val resolvedConfig = TaskKey[Option[File]]("jshint-resolved-config", "The actual location of a JSHint configuration file if present. If jshint-config is none then the task will seek a .jshintrc in the project folder. If that's not found then .jshintrc will be searched for in the user's home folder. This behaviour is consistent with other JSHint tooling.")

}

import WebKeys._
import JsTaskKeys._
import JshintKeys._

implicit val opInputHasher =
OpInputHasher[File](source => OpInputHash.hashString(source + "|" + jshintOptions))

def jshintSettings = jsTaskSettings ++ Seq(

config := None,
resolvedConfig := {
config.value.orElse {
val JsHintRc = ".jshintrc"
val projectRc = baseDirectory.value / JsHintRc
if (projectRc.exists()) {
Some(projectRc)
} else {
val homeRc = file(System.getProperty("user.home")) / JsHintRc
if (homeRc.exists()) {
Some(homeRc)
} else {
None
}
}
}: Option[File]
},
jshintOptions := {
resolvedConfig.value
.map(IO.read(_))
.getOrElse("{}"): String
},

fileInputHasher := OpInputHasher[File](source => OpInputHash.hashString(source + "|" + jshintOptions.value)),

jshint in Assets := jsTask(Assets, jsFilter, jshintOptions, fileInputHasher, "JavaScript linting"),
jshint in TestAssets := jsTask(TestAssets, jsFilter, jshintOptions, fileInputHasher, "JavaScript test linting"),

jsTasks in Assets += jshint in Assets,
jsTasks in TestAssets += jshint in TestAssets

)

}
```

i.e. a `jsTask` is introduced that takes the filter of files to work with ("*.js" in the case above) and stringified json describing any options. The above plugin is actually more complex than most given that its resolution of JavaScript options is quite involved i.e. .jshintrc is looked for in the project folder, then the home folder of the user etc.