Repository: mantoni/eslint_d.js (js)

Issue:
Absence of --fix-to-stdout
Hi,

thank you very much for maintaining eslint_d that I heavily rely on and congratulations for v14 :fireworks:!

The previous version of eslint_d had a `--fix-to-stdout` option that [eslintd-fix](https://github.com/aaronjensen/eslintd-fix) (an Emacs package) relied on. Other tools such as [reformatter](https://github.com/purcell/emacs-reformatter) also relies on software reading from stdin and writting to stdout.

Is this feature planned to be ported to the new architecture?

Pull request description:
Add --fix-to-stdout back
This includes the work from @DamienCassou made in #298 with  more unit tests and documentation.

Fixes #295


Patch:
diff --git a/README.md b/README.md
index 8b186a6..ab6dd43 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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.
@@ -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)
@@ -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.
diff --git a/lib/config.js b/lib/config.js
index 8ebe5fd..0e1528d 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -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 {
diff --git a/lib/forwarder.js b/lib/forwarder.js
index e5a1a8d..a60edb3 100644
--- a/lib/forwarder.js
+++ b/lib/forwarder.js
@@ -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) {
@@ -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) {
@@ -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() {
diff --git a/lib/help.js b/lib/help.js
index 9ae73f2..2a5a2f2 100644
--- a/lib/help.js
+++ b/lib/help.js
@@ -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
 


Test patch:
diff --git a/lib/config.test.js b/lib/config.test.js
index 0873752..0854014 100644
--- a/lib/config.test.js
+++ b/lib/config.test.js
@@ -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', () => {
diff --git a/lib/forwarder.test.js b/lib/forwarder.test.js
index c97c998..874a113 100644
--- a/lib/forwarder.test.js
+++ b/lib/forwarder.test.js
@@ -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;
 
@@ -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());
@@ -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);
     });
@@ -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', () => {
@@ -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}`);
+      });
+    });
   });
 });
diff --git a/test/test.integration.js b/test/test.integration.js
index a1a91c6..34bb33d 100644
--- a/test/test.integration.js
+++ b/test/test.integration.js
@@ -4,6 +4,15 @@ import fs from 'node:fs/promises';
 import { createRequire } from 'node:module';
 import { assert, refute } from '@sinonjs/referee-sinon';
 
+const SUPPORTED_ESLINT_VERSIONS = [
+  'v4.0.x',
+  'v5.0.x',
+  'v6.0.x',
+  'v7.0.x',
+  'v8.0.x',
+  'v9.0.x'
+];
+
 describe('integration tests', () => {
   const eslint_d = path.resolve('bin/eslint_d.js');
   const require = createRequire(import.meta.url);
@@ -34,6 +43,20 @@ describe('integration tests', () => {
     });
   }
 
+  /**
+   * @param {string} config
+   * @returns {() => Promise<void>}
+   */
+  function unlinkHook(config) {
+    return async () => {
+      try {
+        await fs.unlink(config);
+      } catch {
+        // ignore
+      }
+    };
+  }
+
   context('--help', () => {
     it('prints help for --help', async () => {
       const { error, stdout, stderr } = await run('--help');
@@ -58,115 +81,187 @@ describe('integration tests', () => {
     });
   });
 
-  ['v4.0.x', 'v5.0.x', 'v6.0.x', 'v7.0.x', 'v8.0.x', 'v9.0.x'].forEach(
-    (fixture) => {
-      context(fixture, () => {
-        const cwd = path.resolve(`test/fixture/${fixture}`);
-        const { version: eslint_version } = require(
-          require.resolve('eslint/package.json', { paths: [cwd] })
+  SUPPORTED_ESLINT_VERSIONS.forEach((fixture) => {
+    context(fixture, () => {
+      const cwd = path.resolve(`test/fixture/${fixture}`);
+      const { version: eslint_version } = require(
+        require.resolve('eslint/package.json', { paths: [cwd] })
+      );
+      const config = `${cwd}/node_modules/eslint/.eslint_d`;
+      let pid;
+
+      after(unlinkHook(config));
+
+      it('--version', async () => {
+        const { error, stdout, stderr } = await run('--version', { cwd });
+
+        assert.equals(
+          stdout,
+          `eslint_d: v${version}, bundled eslint: v${bundled_version}\n`
         );
-        const config = `${cwd}/node_modules/eslint/.eslint_d`;
-        let pid;
-
-        after(async () => {
-          try {
-            await fs.unlink(config);
-          } catch {
-            // ignore
-          }
-        });
+        assert.equals(stderr, '');
+        assert.isNull(error);
+      });
 
-        it('--version', async () => {
-          const { error, stdout, stderr } = await run('--version', { cwd });
+      it('status not running', async () => {
+        await assert.rejects(fs.stat(config));
 
-          assert.equals(
-            stdout,
-            `eslint_d: v${version}, bundled eslint: v${bundled_version}\n`
-          );
-          assert.equals(stderr, '');
-          assert.isNull(error);
-        });
+        const { error, stdout, stderr } = await run('status', { cwd });
 
-        it('status not running', async () => {
-          await assert.rejects(fs.stat(config));
+        assert.equals(
+          stdout,
+          `eslint_d: Not running - local eslint v${eslint_version}\n`
+        );
+        assert.equals(stderr, '');
+        assert.isNull(error);
+      });
 
-          const { error, stdout, stderr } = await run('status', { cwd });
+      it('start', async () => {
+        const { error, stdout, stderr } = await run('start', { cwd });
 
-          assert.equals(
-            stdout,
-            `eslint_d: Not running - local eslint v${eslint_version}\n`
-          );
-          assert.equals(stderr, '');
-          assert.isNull(error);
-        });
+        assert.equals(stdout, '');
+        assert.equals(stderr, '');
+        assert.isNull(error);
+        await assert.resolves(fs.stat(config));
+      });
 
-        it('start', async () => {
-          const { error, stdout, stderr } = await run('start', { cwd });
+      it('status running', async () => {
+        const raw = await fs.readFile(config, 'utf8');
+        [, , pid] = raw.split(' ');
 
-          assert.equals(stdout, '');
-          assert.equals(stderr, '');
-          assert.isNull(error);
-          await assert.resolves(fs.stat(config));
-        });
+        const { error, stdout, stderr } = await run('status', { cwd });
+
+        assert.equals(
+          stdout,
+          `eslint_d: Running (${pid}) - local eslint v${eslint_version}\n`
+        );
+        assert.equals(stderr, '');
+        assert.isNull(error);
+        refute.exception(() => process.kill(Number(pid), 0));
+      });
 
-        it('status running', async () => {
-          const raw = await fs.readFile(config, 'utf8');
-          [, , pid] = raw.split(' ');
+      it('pass.js', async () => {
+        const { error, stdout, stderr } = await run('../pass.js', { cwd });
 
-          const { error, stdout, stderr } = await run('status', { cwd });
+        assert.equals(stdout, '');
+        assert.equals(stderr, '');
+        assert.isNull(error);
+      });
 
-          assert.equals(
-            stdout,
-            `eslint_d: Running (${pid}) - local eslint v${eslint_version}\n`
-          );
-          assert.equals(stderr, '');
-          assert.isNull(error);
-          refute.exception(() => process.kill(Number(pid), 0));
-        });
+      it('fail.js', async () => {
+        const { error, stdout, stderr } = await run('../fail.js', { cwd });
 
-        it('pass.js', async () => {
-          const { error, stdout, stderr } = await run('../pass.js', { cwd });
+        assert.match(stdout, '/test/fixture/fail.js');
+        assert.match(stdout, 'Strings must use singlequote');
+        refute.isNull(error);
+        assert.equals(error?.['code'], 1);
+        assert.equals(stderr, '');
+      });
 
-          assert.equals(stdout, '');
-          assert.equals(stderr, '');
-          assert.isNull(error);
+      it('--stdin', async () => {
+        const { error, stdout, stderr } = await run('--stdin', {
+          cwd,
+          stdin: `/* eslint quotes: ["error", "single"] */
+              console.log("hello");`
         });
 
-        it('fail.js', async () => {
-          const { error, stdout, stderr } = await run('../fail.js', { cwd });
+        assert.match(stdout, '<text>');
+        assert.match(stdout, 'Strings must use singlequote');
+        assert.equals(stderr, '');
+        refute.isNull(error);
+        assert.equals(error?.['code'], 1);
+      });
 
-          assert.match(stdout, '/test/fixture/fail.js');
-          assert.match(stdout, 'Strings must use singlequote');
-          refute.isNull(error);
-          assert.equals(error?.['code'], 1);
-          assert.equals(stderr, '');
-        });
+      it('stop', async () => {
+        const { error, stdout, stderr } = await run('stop', { cwd });
 
-        it('--stdin', async () => {
-          const { error, stdout, stderr } = await run('--stdin', {
-            cwd,
-            stdin: `/* eslint quotes: ["error", "single"] */
-              console.log("hello");`
+        assert.equals(stdout, '');
+        assert.equals(stderr, '');
+        assert.isNull(error, stdout);
+        await assert.rejects(fs.stat(config));
+        await new Promise((resolve) => setTimeout(resolve, 50));
+        assert.exception(() => process.kill(Number(pid), 0));
+      });
+    });
+  });
+
+  context('--fix-to-stdout', () => {
+    SUPPORTED_ESLINT_VERSIONS.filter(
+      (fixture) => fixture !== 'v4.0.x' // v4 misses --fix-dry-run
+    ).forEach((fixture) => {
+      context(fixture, () => {
+        const cwd = path.resolve(`test/fixture/${fixture}`);
+        const config = `${cwd}/node_modules/eslint/.eslint_d`;
+
+        after(unlinkHook(config));
+
+        const run_args = `--fix-to-stdout --stdin --stdin-filename ${cwd}/../foo.js`;
+
+        context('when file only contains fixable problems', () => {
+          it('prints input if no change is needed', async () => {
+            const stdin = `console.log('Hello eslint');`;
+            const { error, stdout, stderr } = await run(run_args, {
+              cwd,
+              stdin
+            });
+
+            assert.equals(stderr, '');
+            assert.equals(stdout, stdin);
+            assert.isNull(error);
           });
 
-          assert.match(stdout, '<text>');
-          assert.match(stdout, 'Strings must use singlequote');
-          assert.equals(stderr, '');
-          refute.isNull(error);
-          assert.equals(error?.['code'], 1);
+          it('prints fixed output if change is needed', async () => {
+            const { error, stdout, stderr } = await run(run_args, {
+              cwd,
+              stdin: `console.log("Hello eslint");`
+            });
+
+            assert.equals(stderr, '');
+            assert.equals(stdout, `console.log('Hello eslint');`);
+            assert.isNull(error);
+          });
         });
 
-        it('stop', async () => {
-          const { error, stdout, stderr } = await run('stop', { cwd });
+        context('when file contains non-fixable problems', () => {
+          it('prints input if no change is needed', async () => {
+            const stdin = `/* eslint radix: "error" */
+            console.log('Hello' + parseInt('087'))`;
+
+            const { error, stdout, stderr } = await run(run_args, {
+              cwd,
+              stdin
+            });
 
-          assert.equals(stdout, '');
-          assert.equals(stderr, '');
-          assert.isNull(error, stdout);
-          await assert.rejects(fs.stat(config));
-          await new Promise((resolve) => setTimeout(resolve, 50));
-          assert.exception(() => process.kill(Number(pid), 0));
+            assert.equals(stderr, '');
+            assert.equals(
+              stdout,
+              `/* eslint radix: "error" */
+            console.log('Hello' + parseInt('087'))`
+            );
+            refute.isNull(error);
+            assert.equals(error?.['code'], 1);
+          });
+
+          it('prints fixed output if change is needed', async () => {
+            const stdin = `/* eslint radix: "error" */
+            console.log("Hello" + parseInt('087'))`;
+
+            const { error, stdout, stderr } = await run(run_args, {
+              cwd,
+              stdin
+            });
+
+            assert.equals(stderr, '');
+            assert.equals(
+              stdout,
+              `/* eslint radix: "error" */
+            console.log('Hello' + parseInt('087'))`
+            );
+            refute.isNull(error);
+            assert.equals(error?.['code'], 1);
+          });
         });
       });
-    }
-  );
+    });
+  });
 });