Repository: okonet/lint-staged
Pull request #913 (2020-09-15 13:57:53)

feat: add ability to use function as config
This PR adds an ability to use a function as config. This function will get an array of all staged files as parameter which can be used with own globs. Currently, we need to pass such function as value of `'*'` in config object which doesn't feel good.

This can be further optimized by not using `micromatch` internally when a function config is detected.

codecov[bot]: # [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/913?src=pr&el=h1) Report
> Merging [#913](https://codecov.io/gh/okonet/lint-staged/pull/913?src=pr&el=desc) into [master](https://codecov.io/gh/okonet/lint-staged/commit/643038d9fcea7611f1828a40bb9033a0c5f0fef1?el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/okonet/lint-staged/pull/913/graphs/tree.svg?width=650&height=150&src=pr&token=LSoEgsjk4O)](https://codecov.io/gh/okonet/lint-staged/pull/913?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master      #913   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           17        18    +1     
  Lines          602       608    +6     
  Branches       142       143    +1     
=========================================
+ Hits           602       608    +6     
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/913?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [lib/formatConfig.js](https://codecov.io/gh/okonet/lint-staged/pull/913/diff?src=pr&el=tree#diff-bGliL2Zvcm1hdENvbmZpZy5qcw==) | `100.00% <100.00%> (ø)` | |
| [lib/index.js](https://codecov.io/gh/okonet/lint-staged/pull/913/diff?src=pr&el=tree#diff-bGliL2luZGV4Lmpz) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/okonet/lint-staged/pull/913?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/913?src=pr&el=footer). Last update [643038d...c20ac9e](https://codecov.io/gh/okonet/lint-staged/pull/913?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

iiroj: Thanks for the PR! I wonder if the `formatConfig` is needed, could the same thing simply be inside the `validateConfig` method as a short-circuit?
SachinShekhar: [Separation of Concerns](https://en.wikipedia.org/wiki/Separation_of_concerns)

`validateConfig` should focus on only validation, nothing else.
Benefit of separate `formatConfig`: If you need to accept strings or arrays as config in the future, it'd be easy to implement thanks to `formatConfig`.
iiroj: Good enough for me! Do you think there are some existing examples In the readme that could use this format instead of the start glob?
SachinShekhar: There was one example and I have already added the new alternative (but, didn't remove existing one because that remains valid). Do you want me to remove that existing example?
iiroj: Let's leave it, since it shows the explicit `'*'` glob.
iiroj: I think the readme could be more clear that you can either export a single function that receives all matched files, or then an object of globs, where each key can be a function (that receives only those matches), string or array of strings.

Otherwise it's good to go IMO!
SachinShekhar: Done.
iiroj: Thoughts, @okonet?

Patch:
diff --git a/README.md b/README.md
index 60508f5..bb71c1e 100644
--- a/README.md
+++ b/README.md
@@ -189,14 +189,40 @@ For example:
 
 going to execute `eslint` and if it exits with `0` code, it will execute `prettier --write` on all staged `*.js` files.
 
-## Using JS functions to customize tasks
+## Using JS configuration file
 
-When supplying configuration in JS format it is possible to define the task as a function, which will receive an array of staged filenames/paths and should return the complete command as a string. It is also possible to return an array of complete command strings, for example when the task supports only a single file input. The function can be either sync or async.
+Writing the configuration file in JavaScript is the most powerful way to configure _lint-staged_ (`lint-staged.config.js`, [similar](https://github.com/okonet/lint-staged/README.md#configuration), or passed via `--config`). From the configuration file, you can export either a single function, or an object.
+
+If the `exports` value is a function, it will receive an array of all staged filenames. You can then build your own matchers for the files, and return a command string, or an array or command strings. These strings are considered complete and should include the filename arguments, if wanted.
+
+If the `exports` value is an object, its keys should be glob matches (like in the normal non-js config format). The values can either be like in the normal config, or individual functions like described above. Instead of receiving all matched files, the functions in the exported object will only receive the staged files matching the corresponding glob key.
+
+### Function signature
+
+The function can also be async:
 
 ```ts
-type TaskFn = (filenames: string[]) => string | string[] | Promise<string | string[]>
+(filenames: string[]) => string | string[] | Promise<string | string[]>
 ```
 
+### Example: Export a function to build your own matchers
+
+```js
+// lint-staged.config.js
+const micromatch = require('micromatch')
+
+module.exports = (allStagedFiles) => {
+    const shFiles =  micromatch(allStagedFiles, ['**/src/**/*.sh']);
+    if (shFiles.length) {
+      return `printf '%s\n' "Script files aren't allowed in src directory" >&2`
+    }
+    const codeFiles = micromatch(allStagedFiles, ['**/*.js', '**/*.ts']);
+    const docFiles = micromatch(allStagedFiles, ['**/*.md']);
+    return [`eslint ${codeFiles.join(' ')}`, `mdl ${docFiles.join(' ')}`];
+  }
+```
+
+
 ### Example: Wrap filenames in single quotes and run once per file
 
 ```js
@@ -226,6 +252,7 @@ module.exports = {
 ```
 
 ### Example: Use your own globs
+It's better to use the [function-based configuration (seen above)](https://github.com/okonet/lint-staged/README.md#example-export-a-function-to-build-your-own-matchers), if your use case is this.
 
 ```js
 // lint-staged.config.js
diff --git a/lib/formatConfig.js b/lib/formatConfig.js
new file mode 100644
index 0000000..376e67a
--- /dev/null
+++ b/lib/formatConfig.js
@@ -0,0 +1,7 @@
+module.exports = function formatConfig(config) {
+  if (typeof config === 'function') {
+    return { '*': config }
+  }
+
+  return config
+}
diff --git a/lib/index.js b/lib/index.js
index 90ac097..f83b453 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -9,6 +9,7 @@ const { PREVENTED_EMPTY_COMMIT, GIT_ERROR, RESTORE_STASH_EXAMPLE } = require('./
 const printTaskOutput = require('./printTaskOutput')
 const runAll = require('./runAll')
 const { ApplyEmptyCommitError, GetBackupStashError, GitError } = require('./symbols')
+const formatConfig = require('./formatConfig')
 const validateConfig = require('./validateConfig')
 
 const errConfigNotFound = new Error('Config could not be found')
@@ -88,7 +89,8 @@ module.exports = async function lintStaged(
     debugLog('Successfully loaded config from `%s`:\n%O', resolved.filepath, resolved.config)
     // resolved.config is the parsed configuration object
     // resolved.filepath is the path to the config file that was found
-    const config = validateConfig(resolved.config)
+    const formattedConfig = formatConfig(resolved.config)
+    const config = validateConfig(formattedConfig)
     if (debug) {
       // Log using logger to be able to test through `consolemock`.
       logger.log('Running lint-staged with the following config:')


Test patch:
diff --git a/test/__snapshots__/validateConfig.spec.js.snap b/test/__snapshots__/validateConfig.spec.js.snap
index 98b1b88..c58ec22 100644
--- a/test/__snapshots__/validateConfig.spec.js.snap
+++ b/test/__snapshots__/validateConfig.spec.js.snap
@@ -1,5 +1,7 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`validateConfig should not throw and should print nothing for function config 1`] = `""`;
+
 exports[`validateConfig should not throw and should print nothing for function task 1`] = `""`;
 
 exports[`validateConfig should not throw and should print nothing for valid config 1`] = `""`;
diff --git a/test/formatConfig.spec.js b/test/formatConfig.spec.js
new file mode 100644
index 0000000..903ce56
--- /dev/null
+++ b/test/formatConfig.spec.js
@@ -0,0 +1,17 @@
+import formatConfig from '../lib/formatConfig'
+
+describe('formatConfig', () => {
+  it('Object config should return as is', () => {
+    const simpleConfig = {
+      '*.js': ['eslint --fix', 'git add'],
+    }
+    expect(formatConfig(simpleConfig)).toEqual(simpleConfig)
+  })
+
+  it('Function config should be converted to object', () => {
+    const functionConfig = (stagedFiles) => [`eslint --fix ${stagedFiles}', 'git add`]
+    expect(formatConfig(functionConfig)).toEqual({
+      '*': functionConfig,
+    })
+  })
+})
diff --git a/test/validateConfig.spec.js b/test/validateConfig.spec.js
index 74a278e..bf6d4a0 100644
--- a/test/validateConfig.spec.js
+++ b/test/validateConfig.spec.js
@@ -2,6 +2,8 @@ import makeConsoleMock from 'consolemock'
 
 import validateConfig from '../lib/validateConfig'
 
+import formatConfig from '../lib/formatConfig'
+
 describe('validateConfig', () => {
   const originalConsole = global.console
   beforeAll(() => {
@@ -36,6 +38,12 @@ describe('validateConfig', () => {
     expect(console.printHistory()).toMatchSnapshot()
   })
 
+  it('should not throw and should print nothing for function config', () => {
+    const functionConfig = (stagedFiles) => [`eslint ${stagedFiles.join(' ')}`]
+    expect(() => validateConfig(formatConfig(functionConfig))).not.toThrow()
+    expect(console.printHistory()).toMatchSnapshot()
+  })
+
   it('should not throw and should print nothing for function task', () => {
     expect(() =>
       validateConfig({