Skip to content

Commit

Permalink
Added watch option to re-run the linters when a file changes ⌚️ (#2)
Browse files Browse the repository at this point in the history
* Added `watch` option to re-run the linters when a file changes ⌚️

* Added watch events for adding/removing a file 🗳️

* Updated test text to increase clarity of functionality 🆑

* Set ignoreInitial on the watcher now that `add` has been added 🪨
  • Loading branch information
01taylop authored Jun 18, 2024
1 parent fd44c72 commit 0e9ef59
Show file tree
Hide file tree
Showing 8 changed files with 349 additions and 34 deletions.
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

0 comments on commit 0e9ef59

Please sign in to comment.