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

Added watch option to re-run the linters when a file changes ⌚️ #2

Merged
merged 4 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
},
"dependencies": {
"chalk": "5.3.0",
"chokidar": "3.6.0",
"commander": "12.1.0",
"eslint": "9.4.0",
"glob": "10.4.1",
Expand Down
162 changes: 162 additions & 0 deletions src/__tests__/watchFiles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { readFile } from 'fs'

import chokidar from 'chokidar'

import { Events } from '@Types'

import { fileChangeEvent, watchFiles } from '../watchFiles'

jest.mock('fs')
jest.mock('chokidar')

describe('watchFiles', () => {
let mockWatcher: chokidar.FSWatcher

const mockReadFile = (content: string) => {
jest.mocked(readFile).mockImplementationOnce((_path: any, _encoding: any, callback?: (error: any, data: any) => void): void => {
if (callback) {
callback(null, content)
}
})
}

beforeEach(() => {
mockWatcher = {
add: jest.fn(),
close: jest.fn(),
on: jest.fn(),
unwatch: jest.fn(),
} as unknown as chokidar.FSWatcher

jest.mocked(chokidar.watch).mockReturnValue(mockWatcher)
})

afterEach(() => {
fileChangeEvent.removeAllListeners()
})

it('initialises chokidar with the correct file and ignore patterns', () => {
const filePatterns = ['**/*.ts']
const ignorePatterns = ['node_modules']

watchFiles({ filePatterns, ignorePatterns })

expect(chokidar.watch).toHaveBeenCalledWith(filePatterns, {
ignored: ignorePatterns,
ignoreInitial: true,
persistent: true,
})
})

it('emits a "FILE_CHANGED" event when saving an existing file (because there is no hash map yet)', done => {
expect.assertions(1)

const mockPath = 'mock/existing-file.ts'
mockReadFile('open-and-save')

fileChangeEvent.on(Events.FILE_CHANGED, params => {
expect(params).toStrictEqual({
message: `File \`${mockPath}\` has been changed.`,
path: mockPath,
})
done()
})

watchFiles({ filePatterns: [mockPath], ignorePatterns: [] })

const changeHandler = (mockWatcher.on as jest.Mock).mock.calls.find(call => call[0] === 'change')[1]
changeHandler(mockPath)
})

it('emits a "FILE_CHANGED" event if the file content changes', done => {
expect.assertions(2)

const mockPath = 'mock/old-file.ts'
mockReadFile('old-content')

fileChangeEvent.on(Events.FILE_CHANGED, params => {
expect(params).toStrictEqual({
message: `File \`${mockPath}\` has been changed.`,
path: mockPath,
})
})

watchFiles({ filePatterns: [mockPath], ignorePatterns: [] })

const changeHandler = (mockWatcher.on as jest.Mock).mock.calls.find(call => call[0] === 'change')[1]
changeHandler(mockPath)

mockReadFile('new-content')
changeHandler(mockPath)

setTimeout(() => {
done()
}, 100)
})

it('does not emit a "FILE_CHANGED" event if the file content did not change', done => {
expect.assertions(1)

const mockPath = 'mock/old-file.ts'
mockReadFile('old-content')

fileChangeEvent.on(Events.FILE_CHANGED, params => {
expect(params).toStrictEqual({
message: `File \`${mockPath}\` has been changed.`,
path: mockPath,
})
})

watchFiles({ filePatterns: [mockPath], ignorePatterns: [] })

const changeHandler = (mockWatcher.on as jest.Mock).mock.calls.find(call => call[0] === 'change')[1]
changeHandler(mockPath)
changeHandler(mockPath)
changeHandler(mockPath)

setTimeout(() => {
done()
}, 100)
})

it('emits a "FILE_CHANGED" event if a file is added', done => {
expect.assertions(1)

const mockPath = 'mock/new-file.ts'
mockReadFile('new-content')

fileChangeEvent.on(Events.FILE_CHANGED, params => {
expect(params).toStrictEqual({
message: `File \`${mockPath}\` has been added.`,
path: mockPath,
})
done()
})

watchFiles({ filePatterns: [mockPath], ignorePatterns: [] })

const changeHandler = (mockWatcher.on as jest.Mock).mock.calls.find(call => call[0] === 'add')[1]
changeHandler(mockPath)
})

it('emits a "FILE_CHANGED" event if a file is removed', done => {
expect.assertions(1)

const mockPath = 'mock/legacy-file.ts'
mockReadFile('legacy-content')

fileChangeEvent.on(Events.FILE_CHANGED, params => {
expect(params).toStrictEqual({
message: `File \`${mockPath}\` has been removed.`,
path: mockPath,
})
done()
})

watchFiles({ filePatterns: [mockPath], ignorePatterns: [] })

const changeHandler = (mockWatcher.on as jest.Mock).mock.calls.find(call => call[0] === 'unlink')[1]
changeHandler(mockPath)
})

})
92 changes: 64 additions & 28 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
#!/usr/bin/env node
import { Command } from 'commander'

import { Linter, type LinterResult } from '@Types'
import { Events, Linter, type LinterResult } from '@Types'
import colourLog from '@Utils/colourLog'
import { notifyResults } from '@Utils/notifier'
import { clearTerminal } from '@Utils/terminal'

import linters from './linters/index'
import sourceFiles from './sourceFiles'
import { fileChangeEvent, watchFiles } from './watchFiles'

const program = new Command()

Expand All @@ -21,7 +23,14 @@ interface RunLinter {
linter: Linter
}

interface RunLintPilot {
debug: boolean
title: string
watch: boolean
}

const runLinter = async ({ debug, filePattern, linter }: RunLinter) => {
// TODO: Handle case where no files are sourced
const startTime = new Date().getTime()
colourLog.info(`Running ${linter.toLowerCase()}...`)

Expand All @@ -39,42 +48,69 @@ const runLinter = async ({ debug, filePattern, linter }: RunLinter) => {
return result
}

const runLintPilot = ({ debug, title, watch }: RunLintPilot) => {
Promise.all([
runLinter({
debug,
filePattern: '**/*.{cjs,js,jsx,mjs,ts,tsx}',
linter: Linter.ESLint,
}),
runLinter({
debug,
filePattern: '**/*.{md,mdx}',
linter: Linter.Markdownlint,
}),
runLinter({
debug,
filePattern: '**/*.{css,scss,less,sass,styl,stylus}',
linter: Linter.Stylelint,
}),
]).then((results) => {
console.log()

results.forEach(({ processedResult }) => {
colourLog.resultBlock(processedResult)
})

const exitCode = notifyResults(results, title)

if (watch) {
colourLog.info('Watching for changes...')
} else {
process.exit(exitCode)
}
})
}

program
.option('-e, --emoji <string>', 'customise the emoji displayed when running lint-pilot', '✈️')
.option('-t, --title <string>', 'customise the title displayed when running lint-pilot', 'Lint Pilot')
.option('-w, --watch', 'watch for file changes and re-run the linters', false)
.option('--debug', 'output additional debug information including the list of files being linted', false)
.action(({ debug, emoji, title }) => {
console.clear()
.action(({ debug, emoji, title, watch }) => {
clearTerminal()
colourLog.title(`${emoji} ${title} ${emoji}`)
console.log()

Promise.all([
runLinter({
debug,
filePattern: '**/*.{cjs,js,jsx,mjs,ts,tsx}',
linter: Linter.ESLint,
}),
runLinter({
debug,
filePattern: '**/*.{md,mdx}',
linter: Linter.Markdownlint,
}),
runLinter({
debug,
filePattern: '**/*.{css,scss,less,sass,styl,stylus}',
linter: Linter.Stylelint,
}),
]).then((results) => {
console.log()

results.forEach(({ processedResult }) => {
colourLog.resultBlock(processedResult)
})
runLintPilot({ debug, title, watch })

const exitCode = notifyResults(results, title)
if (watch) {
watchFiles({
filePatterns: [
'**/*.{cjs,js,jsx,mjs,ts,tsx}',
'**/*.{md,mdx}',
'**/*.{css,scss,less,sass,styl,stylus}',
],
ignorePatterns: ['**/+(coverage|node_modules)/**'],
})

process.exit(exitCode)
})
fileChangeEvent.on(Events.FILE_CHANGED, ({ message }) => {
clearTerminal()
colourLog.info(message)
console.log()
runLintPilot({ debug, title, watch })
})
}
})

program.parse(process.argv)
5 changes: 5 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
enum Events {
FILE_CHANGED = 'FILE_CHANGED',
}

enum Linter {
ESLint = 'ESLint',
Markdownlint = 'MarkdownLint',
Expand All @@ -24,5 +28,6 @@ export type {
}

export {
Events,
Linter,
}
14 changes: 14 additions & 0 deletions src/utils/__tests__/terminal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { clearTerminal } from '../terminal'

describe('clearTerminal', () => {

it('calls process.stdout.write to clear the terminal', () => {
const mockWrite = jest.spyOn(process.stdout, 'write').mockImplementation(() => true)

clearTerminal()

expect(mockWrite).toHaveBeenCalledWith('\x1Bc\x1B[3J\x1B[2J\x1B[H')
mockWrite.mockRestore()
})

})
5 changes: 5 additions & 0 deletions src/utils/terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const clearTerminal = () => process.stdout.write('\x1Bc\x1B[3J\x1B[2J\x1B[H')

export {
clearTerminal,
}
58 changes: 58 additions & 0 deletions src/watchFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { createHash } from 'crypto'
import { EventEmitter } from 'events'
import { readFile } from 'fs'

import chokidar from 'chokidar'

import { Events } from '@Types'

interface WatchFiles {
filePatterns: Array<string>
ignorePatterns: Array<string>
}

const fileChangeEvent = new EventEmitter()

const fileHashes = new Map<string, string>()

const getHash = (data: string) => createHash('md5').update(data).digest('hex')

const watchFiles = ({ filePatterns, ignorePatterns }: WatchFiles) => {
const watcher = chokidar.watch(filePatterns, {
ignored: ignorePatterns,
ignoreInitial: true,
persistent: true,
})

watcher.on('add', (path, _stats) => {
fileChangeEvent.emit(Events.FILE_CHANGED, {
message: `File \`${path}\` has been added.`,
path,
})
})

watcher.on('change', (path, _stats) => {
readFile(path, 'utf8', (_error, data) => {
const newHash = getHash(data)
if (fileHashes.get(path) !== newHash) {
fileHashes.set(path, newHash)
fileChangeEvent.emit(Events.FILE_CHANGED, {
message: `File \`${path}\` has been changed.`,
path,
})
}
})
})

watcher.on('unlink', path => {
fileChangeEvent.emit(Events.FILE_CHANGED, {
message: `File \`${path}\` has been removed.`,
path,
})
})
}

export {
fileChangeEvent,
watchFiles,
}
Loading
Loading