Formating code with a CLI tool

I’ve come up with a nice pattern for formatting code using an external CLI tool. Many languages already have a tool to format code, but they often assume a developer will run them from a command line.

This advice assumes that:

  • The formatter is an existing CLI tool
  • that takes unformatted code as STDIN
  • and outputs formatted code to STDOUT

Writing to STDIN

First we need the process to read and write:

// This is equivalent to `/usr/local/bin/formatter --no-color -`
const process = new Process("/usr/local/bin/formatter", {
  args: ["--no-color", "-"],
  stdio: "pipe",
});

stdio: "pipe" ensures that we can read to and write from the process. Here’s how we write to it:

const writeToStdin = (process, unformattedText) => {
  const writer = process.stdin.getWriter();
  writer.ready.then(() => {
    writer.write(unformattedText);
    writer.close();
  });
}

Collecting the Formatted Text From STDOUT

The process will send the formatted code to STDOUT one line at a time. We write a small, single-line function to collect every line:

// The function to collect STDOUT
const collectOutputText = (stdout, buffer) => (buffer.stdout += stdout);

// And how we connect it to our process
let buffer = { stdout: "", stderr: "" };
process.onStdout((stdout) => collectOutputText(stdout, buffer));

We capture STDOUT inside an object instead of a string variable because JavaScript does not have “pass-by-reference”. If we tried to pass a string variable, we’d only capture the last line because the variable would start from scratch every time it was passed. However passing an object will hold onto that value.

We can do the exact same thing for STDERR:

const collectErrorText = (stderr, buffer) => (buffer.stderr += stderr);

// Use the same `buffer` object from before
process.onStdout((stdout) => collectOutputText(stdout, buffer));

Making a Promise

We want to be able to treat this like a command line process. We will:

  • Pass in the unformatted text
  • Return the formatted text
  • Or respond to an error

Eventually we’ll be able to use it like this:

const myFormatFunction = require("./myFormatFunction");

myFormatFunction(unformattedText)
  .then((formattedText) => {
    /* replace the document with formattedText */
  })
  .catch((error) => {
    /* do something with the error */
  });

This works by returning a Promise. Here’s all the (simplified) code:

const myFormatFunction = (unformattedText) => {
  const writeToStdin = // from before
  const collectOutputText = // from before
  const collectErrorText = // from before

  return new Promise((resolve, reject) => {
    try {
      const process = // from before
      let buffer = { stdout: "", stderr: "" };

      // process.onStdout from before
      // process.onStderr from before
      process.onDidExit((status) => {
        if (status === 0) {
          resolve(buffer.stdout);
        } else {
          reject(buffer.stderr);
        }
      });

      writeToStdin(process, unformattedText); // writeToStdin from before
      process.start();
    } catch (err) { // just in case this code has a bug
      reject(err);
    }
  });
}

The benefit of returning a Promise is that it works really well with TextEditor's onWillSave method which is perfect for the common “format on save” functionality. The returned Promise ensures that Nova waits at least 5 seconds while the formatter runs. Once we resolve the Promise, the document saves with the newly formatted text. If we reject, we can inform the user something went wrong instead.

Bonus: Format on Save

Here’s how I use this function in context:

editor.onWillSave((editor) => {
  const documentSpan = new Range(0, editor.document.length);
  const unformattedText = editor.document.getTextInRange(documentSpan);
  return myFormatFunction(unformattedText)
    .then((formattedText) =>
      editor.edit((edit) => edit.replace(documentSpan, text))
    )
    .catch(/* console.error? show a notification? up to you! */);
});

I’ve found this to be a really nice separation of concerns. One function handles the actual formatting of the code and knows nothing about the document or Nova APIs. A separate function deals with the document stuff like collecting the initial text and interacting with Nova.

If you want real-world example of this, you can see one in my Crystal extension here.

I hope you found this useful. Happy to answer any questions.

12 Likes

Awesome! I hope this post of yours gets ‘promoted’ to an official tutorial!

2 Likes

@edwardloveall Thank you for taking the time to explain! It works great. Do you know if there’s a way so that, after activating the extension, the editor doesn’t have to reload the file? Right now there’s a slight delay when the text “re-appears” as you scroll down. (Pretty sure it’s not because of your code, but this doesn’t happen with the other formatting extensions I’ve tested – e.g. Prettier.)

Hi @MaximePigeon. You’re welcome! Glad it was helpful.

Do you know if there’s a way so that, after activating the extension, the editor doesn’t have to reload the file? Right now there’s a slight delay when the text “re-appears” as you scroll down.

The delay I see is when a file is saved, the Process does its work and then writes to the file which briefly flickers as the contents are replaced. Is that what you’re seeing? Otherwise, I’m not sure what you mean about the “scrolling down”.

If so, I’m not sure there’s a way around that when formatting with an external process. I know the Prettier plugin does this an entirely different way making heavy use of JSON-RPC which I haven’t looked into much. I believe that they’re actually importing the prettier code which can run inside of Nova instead of an external process. This is possible since both Prettier and Nova’s runtime are javascript based.

Does that help? You might also try opening a new thread about this if it’s still an issue. I bet most people wouldn’t think to look in this thread if they had your same question.

Just chiming in to say that this worked great for the phpcs extension I maintain. Thanks for making this post! I could not get the writer to work correctly just reading the documentation so it was excellent to see a working example here :clap:

2 Likes