Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/issue-1398: added support for custom configuration #1401

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
119 changes: 97 additions & 22 deletions lib/makeCmdTasks.js
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file has started to become a bit complex... I wonder if we could split it somehow, maybe different handling for function and regular config.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -1,10 +1,97 @@
import debug from 'debug'

import { configurationError } from './messages.js'
import { resolveTaskFn } from './resolveTaskFn.js'
import { makeErr, resolveTaskFn } from './resolveTaskFn.js'
import { getInitialState } from './state.js'

const debugLog = debug('lint-staged:makeCmdTasks')

/**
* Returns whether the command is a function or not and the resolved command
*
* @param {Function|string} cmd
* @param {Array<string>} files
* @returns {Object} Object containing whether the command is a function and the resolved command
*/
const getResolvedCommand = async (cmd, files) => {
// command function may return array of commands that already include `stagedFiles`
const isFn = typeof cmd === 'function'
/** Pass copy of file list to prevent mutation by function from config file. */
const resolved = isFn ? await cmd([...files]) : cmd
return { resolved, isFn }
}

/**
* Validates whether a command is a function and if the command is valid
*
* @param {string|object} command
* @param {boolean} isFn
* @param {string|object} resolved
* @throws {Error} If the command is not valid
*/
const validateCommand = (command, isFn, resolved) => {
if ((isFn && typeof command !== 'string' && typeof command !== 'object') || !command) {
throw new Error(
configurationError(
'[Function]',
'Function task should return a string or an array of strings or an object',
resolved
)
)
}
}

/**
* Handles function configuration and pushes the tasks into the task array
*
* @param {object} command
* @param {Array} cmdTasks
* @param {string|object} resolved
* @throws {Error} If the function configuration is not valid
*/
const handleFunctionConfig = (command, cmdTasks, resolved) => {
if (typeof command.title === 'string' && typeof command.task === 'function') {
const task = async (ctx = getInitialState()) => {
try {
await command.task()
} catch (e) {
throw makeErr(command.title, e, ctx)
}
}
cmdTasks.push({
title: command.title,
task,
})
} else {
throw new Error(
configurationError(
'[Function]',
'Function task should return object with title and task where title should be string and task should be function',
resolved
)
)
}
}

/**
* Handles regular configuration and pushes the tasks into the task array
*
* @param {object} params
* @param {Array} cmdTasks
*/
const handleRegularConfig = ({ command, cwd, files, gitDir, isFn, shell, verbose }, cmdTasks) => {
const task = resolveTaskFn({ command, cwd, files, gitDir, isFn, shell, verbose })
cmdTasks.push({ title: command, command, task })
}

/**
* Ensures the input is an array. If the input is not an array, it wraps the input inside an array.
*
* @param {Array|string|object} input
* @returns {Array} Returns the input as an array
*/
const ensureArray = (input) => (Array.isArray(input) ? input : [input])

/**
* Creates and returns an array of listr tasks which map to the given commands.
*
Expand All @@ -18,33 +105,21 @@ const debugLog = debug('lint-staged:makeCmdTasks')
*/
export const makeCmdTasks = async ({ commands, cwd, files, gitDir, shell, verbose }) => {
debugLog('Creating listr tasks for commands %o', commands)
const commandArray = Array.isArray(commands) ? commands : [commands]
const commandArray = ensureArray(commands)
const cmdTasks = []

for (const cmd of commandArray) {
// command function may return array of commands that already include `stagedFiles`
const isFn = typeof cmd === 'function'

/** Pass copy of file list to prevent mutation by function from config file. */
const resolved = isFn ? await cmd([...files]) : cmd

const resolvedArray = Array.isArray(resolved) ? resolved : [resolved] // Wrap non-array command as array
const { resolved, isFn } = await getResolvedCommand(cmd, files)
const resolvedArray = ensureArray(resolved)

for (const command of resolvedArray) {
// If the function linter didn't return string | string[] it won't work
// Do the validation here instead of `validateConfig` to skip evaluating the function multiple times
if (isFn && typeof command !== 'string') {
throw new Error(
configurationError(
'[Function]',
'Function task should return a string or an array of strings',
resolved
)
)
}
validateCommand(command, isFn, resolved)

const task = resolveTaskFn({ command, cwd, files, gitDir, isFn, shell, verbose })
cmdTasks.push({ title: command, command, task })
if (isFn && typeof command === 'object') {
handleFunctionConfig(command, cmdTasks, resolved)
} else {
handleRegularConfig({ command, cwd, files, gitDir, isFn, shell, verbose }, cmdTasks)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/resolveTaskFn.js
Expand Up @@ -108,7 +108,7 @@ const interruptExecutionOnError = (ctx, execaChildProcess) => {
* @param {Object} ctx
* @returns {Error}
*/
const makeErr = (command, result, ctx) => {
export const makeErr = (command, result, ctx) => {
ctx.errors.add(TaskError)

// https://nodejs.org/api/events.html#error-events
Expand Down
46 changes: 44 additions & 2 deletions test/unit/makeCmdTasks.spec.js
Expand Up @@ -112,14 +112,14 @@ describe('makeCmdTasks', () => {
expect(res[0].title).toEqual('test')
})

it("should throw when function task doesn't return string | string[]", async () => {
it("should throw when function task doesn't return string | string[] | object", async () => {
await expect(makeCmdTasks({ commands: () => null, gitDir, files: ['test.js'] })).rejects
.toThrowErrorMatchingInlineSnapshot(`
"✖ Validation Error:

Invalid value for '[Function]': null

Function task should return a string or an array of strings"
Function task should return a string or an array of strings or an object"
`)
})

Expand All @@ -143,4 +143,46 @@ describe('makeCmdTasks', () => {
/** ...but the original file list was not mutated */
expect(files).toEqual(['test.js'])
})

it('should work with function task returning an object with title and task', async () => {
const res = await makeCmdTasks({
commands: () => ({ title: 'test', task: () => {} }),
gitDir,
files: ['test.js'],
})
expect(res.length).toBe(1)
expect(res[0].title).toEqual('test')
expect(typeof res[0].task).toBe('function')
})

it('should throw error when function task returns object without proper title and task', async () => {
await expect(
makeCmdTasks({
commands: () => ({ title: 'test' }), // Missing task function
gitDir,
files: ['test.js'],
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"✖ Validation Error:

Invalid value for '[Function]': { title: 'test' }

Function task should return object with title and task where title should be string and task should be function"
`)
})

it('should throw error when function task fails', async () => {
const failingTask = () => {
throw new Error('Task failed')
}

const res = await makeCmdTasks({
commands: () => ({ title: 'test', task: failingTask }),
gitDir,
files: ['test.js'],
})

const [linter] = res
await expect(linter.task()).rejects.toThrow()
})
})