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

Add --fix-to-stdout back #307

Merged
merged 4 commits into from
Aug 2, 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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ All arguments are passed to eslint, except for the following commands:
status Show daemon status, process id and resolved eslint version
--help, -h Show this help
--version, -v Show version number of eslint_d and bundled eslint
--fix-to-stdout Print fixed file to stdout (requires --stdin)
```

## Environment variables
Expand All @@ -121,6 +122,29 @@ All arguments are passed to eslint, except for the following commands:
bundled eslint (default). "fail" logs an error and exits with code 1.
"ignore" silently exits with code 0.

## Automatic fixing

`eslint_d` has an additional option that `eslint` does not have,
`--fix-to-stdout` which prints the fixed file to stdout. This allows editors to
add before save hooks to automatically fix a file prior to saving. It must be
used with `--stdin`.

### Vim

Add this to your `.vimrc` to lint the current buffer or visual selection on
`<leader>f`:

```vim
" Autofix entire buffer with eslint_d:
nnoremap <leader>f mF:%!eslint_d --stdin --fix-to-stdout --stdin-filename %<CR>`F
" Autofix visual selection with eslint_d:
vnoremap <leader>f :!eslint_d --stdin --fix-to-stdout<CR>gv
```

### Emacs

See [eslintd-fix](https://github.com/aaronjensen/eslintd-fix)

## How does this work?

`eslint_d` starts a background server that runs `eslint` in a separate process.
Expand Down Expand Up @@ -152,7 +176,7 @@ changed: `package.json`, `package-lock.json`, `npm-shrinkwrap.json`,

## Compatibility

- `14.0.0`: eslint 4 - 9, node 18 - 22 (ships with eslint 9)
- `14.0.0`: eslint 4 - 9, node 18 - 22 (ships with eslint 9) (see [^1])
- `13.0.0`: eslint 4 - 8, node 12 - 20 (ships with eslint 8)
- `12.0.0`: eslint 4 - 8, node 12 - 16 (ships with eslint 8)
- `11.0.0`: eslint 4 - 8, node 12 - 16 (ships with eslint 7)
Expand All @@ -177,3 +201,5 @@ MIT
[syntastic]: https://github.com/scrooloose/syntastic
[flycheck]: http://www.flycheck.org/
[SublimeLinter-eslint]: https://github.com/SublimeLinter/SublimeLinter-eslint

[^1]: The support for `--fix-to-stdout` is only provided with eslint 5 and beyond.
7 changes: 6 additions & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ import fs from 'node:fs/promises';
* @returns {Promise<Config | null>}
*/
export async function loadConfig(resolver) {
const filename = configFile(resolver);
try {
const raw = await fs.readFile(configFile(resolver), 'utf8');
let raw = await fs.readFile(filename, 'utf8');
if (!raw) {
await new Promise((resolve) => setTimeout(resolve, 50));
raw = await fs.readFile(filename, 'utf8');
}
const [token, port, pid, hash] = raw.split(' ');
return { token, port: Number(port), pid: Number(pid), hash };
} catch {
Expand Down
23 changes: 23 additions & 0 deletions lib/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,29 @@ describe('lib/config', () => {

await assert.resolves(promise, null);
});

it('retries reading the file if content was empty', async () => {
const clock = sinon.useFakeTimers();
const contents = ['', 'token 123 456 hash'];
sinon.replace(
fs,
'readFile',
sinon.fake(() => Promise.resolve(contents.shift()))
);

const promise = loadConfig(resolver);
await Promise.resolve();
assert.calledOnce(fs.readFile);
clock.tick(50);

await assert.resolves(promise, {
token: 'token',
port: 123,
pid: 456,
hash: 'hash'
});
assert.calledTwice(fs.readFile);
});
});

context('writeConfig', () => {
Expand Down
54 changes: 47 additions & 7 deletions lib/forwarder.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,35 @@ const EXIT_TOKEN_LENGTH = 7;
* @param {Config} config
*/
export async function forwardToDaemon(resolver, config) {
const eslint_args = process.argv.slice();
const text = process.argv.includes('--stdin') ? await readStdin() : null;
const { stdout } = supportsColor;

const fix_to_stdout_index = eslint_args.indexOf('--fix-to-stdout');
const fix_to_stdout = fix_to_stdout_index !== -1;

if (fix_to_stdout) {
if (!eslint_args.includes('--stdin')) {
console.error('--fix-to-stdout requires passing --stdin as well');
// eslint-disable-next-line require-atomic-updates
process.exitCode = 1;
return;
}
eslint_args.splice(
fix_to_stdout_index,
1,
'--fix-dry-run',
'--format',
'json'
);
}

const socket = net.connect(config.port, '127.0.0.1');
const args = [
config.token,
stdout ? stdout.level : 0,
process.cwd(),
process.argv
eslint_args
];
socket.write(JSON.stringify(args));
if (text) {
Expand All @@ -39,16 +59,24 @@ export async function forwardToDaemon(resolver, config) {
let chunk = '';
while ((chunk = socket.read()) !== null) {
content += chunk;
if (content.length > EXIT_TOKEN_LENGTH) {
const message_length = content.length - EXIT_TOKEN_LENGTH;
// Write everything we are sure doesn't contain the termination code:
process.stdout.write(content.substring(0, message_length));
// Keep only what we haven't written yet:
content = content.substring(message_length);
if (!fix_to_stdout && content.length > EXIT_TOKEN_LENGTH) {
process.stdout.write(flushMessage());
}
}
})
.on('end', () => {
if (fix_to_stdout) {
try {
const { output } = JSON.parse(flushMessage())[0];
process.stdout.write(output || text);
} catch (err) {
process.stdout.write(text);
console.error(`eslint_d: ${err}`);
process.exitCode = 1;
return;
}
}

// The remaining 'content' must be the termination code:
const match = content.match(EXIT_TOKEN_REGEXP);
if (match) {
Expand All @@ -69,6 +97,18 @@ export async function forwardToDaemon(resolver, config) {
}
process.exitCode = 1;
});

/**
* @returns {string}
*/
function flushMessage() {
const message_length = content.length - EXIT_TOKEN_LENGTH;
// Extract everything we are sure doesn't contain the termination code:
const message = content.substring(0, message_length);
// Keep only what we haven't written yet:
content = content.substring(message_length);
return message;
}
}

function readStdin() {
Expand Down
117 changes: 111 additions & 6 deletions lib/forwarder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('lib/forwarder', () => {
throw new Error('Failed to create resolver');
}
const config = { token: 'token', port: 123, pid: 456, hash: 'hash' };
const color_level = supportsColor.stdout?.['level'] || 0;
let socket;
let argv;

Expand All @@ -20,6 +21,12 @@ describe('lib/forwarder', () => {
return this;
}

function fakeStdin(text) {
const stdin = new PassThrough();
stdin.end(text);
sinon.replaceGetter(process, 'stdin', () => stdin);
}

beforeEach(() => {
socket = new PassThrough();
sinon.replace(socket, 'write', sinon.fake());
Expand All @@ -45,7 +52,7 @@ describe('lib/forwarder', () => {

assert.calledOnceWith(
socket.write,
`["token",${supportsColor.stdout?.['level'] || 0},"the/cwd",["node","eslint_d"]]`
`["token",${color_level},"the/cwd",["node","eslint_d"]]`
);
assert.calledOnce(socket.end);
});
Expand All @@ -71,18 +78,15 @@ describe('lib/forwarder', () => {
});

it('writes text from stdin to socket', async () => {
const text = 'text from stdin';
const stdin = new PassThrough();
fakeStdin('text from stdin');
argv.push('--stdin');
sinon.replaceGetter(process, 'stdin', () => stdin);

forwardToDaemon(resolver, config);
stdin.end(text);
await new Promise(setImmediate);

assert.calledThrice(socket.write);
assert.calledWith(socket.write, '\n');
assert.calledWith(socket.write, text);
assert.calledWith(socket.write, 'text from stdin');
});

it('forwards socket response to stdout', () => {
Expand Down Expand Up @@ -222,5 +226,106 @@ describe('lib/forwarder', () => {
assert.equals(process.exitCode, 1);
assert.calledOnceWith(fs.unlink, `${resolver.base}/.eslint_d`);
});

context('--fix-to-stdout', () => {
beforeEach(() => {
sinon.replace(process, 'cwd', sinon.fake.returns('cwd'));
fakeStdin('text from stdin');
});

it('throws if --stdin is absent', async () => {
argv.push('--fix-to-stdout');

await forwardToDaemon(resolver, config);

assert.equals(process.exitCode, 1);
assert.calledOnceWith(
console.error,
'--fix-to-stdout requires passing --stdin as well'
);
});

it('replaces the option with --fix-dry-run --format json', async () => {
argv.push('--stdin', '--fix-to-stdout', '--other', '--options');

forwardToDaemon(resolver, config);
await new Promise(setImmediate);

assert.calledThrice(socket.write);
assert.calledWith(
socket.write,
`["token",${color_level},"cwd",["node","eslint_d","--stdin","--fix-dry-run","--format","json","--other","--options"]]`
);
assert.calledWith(socket.write, '\n');
assert.calledWith(socket.write, 'text from stdin');
assert.calledOnce(socket.end);
});

it('prints fixed output to stdout', async () => {
argv.push('--stdin', '--fix-to-stdout');
const chunks = ['[{"output":"response from daemon"}]EXIT001'];
sinon.replace(
socket,
'read',
sinon.fake(() => (chunks.length ? chunks.shift() : null))
);
sinon.replace(process.stdout, 'write', sinon.fake());

forwardToDaemon(resolver, config);
await new Promise(setImmediate);
socket.on.firstCall.callback(); // readable
socket.on.secondCall.callback(); // end

assert.calledWith(process.stdout.write, 'response from daemon');
assert.equals(process.exitCode, 1);
refute.called(console.error);
});

it('prints original input to stdout if no output', async () => {
argv.push('--stdin', '--fix-to-stdout');
const chunks = ['[{}]EXIT000'];
sinon.replace(
socket,
'read',
sinon.fake(() => (chunks.length ? chunks.shift() : null))
);
sinon.replace(process.stdout, 'write', sinon.fake());

forwardToDaemon(resolver, config);
await new Promise(setImmediate);
socket.on.firstCall.callback(); // readable
socket.on.secondCall.callback(); // end

assert.calledWith(process.stdout.write, 'text from stdin');
assert.equals(process.exitCode, 0);
refute.called(console.error);
});

it('prints error to stderr and original input to stdout if output cannot be parsed', async () => {
argv.push('--stdin', '--fix-to-stdout');
const chunks = ['NotJSON!EXIT000'];
sinon.replace(
socket,
'read',
sinon.fake(() => (chunks.length ? chunks.shift() : null))
);
sinon.replace(process.stdout, 'write', sinon.fake());

forwardToDaemon(resolver, config);
await new Promise(setImmediate);
socket.on.firstCall.callback(); // readable
socket.on.secondCall.callback(); // end

assert.calledWith(process.stdout.write, 'text from stdin');
assert.equals(process.exitCode, 1);
let error;
try {
JSON.parse('NotJSON!');
} catch (err) {
error = err;
}
assert.calledOnceWith(console.error, `eslint_d: ${error}`);
});
});
});
});
1 change: 1 addition & 0 deletions lib/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All arguments are passed to eslint, except for the following commands:
stop Stop the daemon
restart Restart the daemon
status Show daemon status, process id and resolved eslint version
--fix-to-stdout Print fixed file to stdout (requires --stdin)
--help, -h Show this help
--version, -v Show version number of eslint_d and bundled eslint

Expand Down
Loading
Loading