Cleaning Up A Trivial Script With Fairmont & FRP

What do you mean, this is too abstract?

Our functional reactive programming library Fairmont makes it easy to write compact, powerful code. But functional programming is hard to explain if you’re new to it. When people try, they often make it more confusing than it needs to be.

We’ve blogged about some of the theory behind Fairmont in the past. It’s interesting stuff, and we’ll talk about it again. But for now, just take a look at how Fairmont and FRP made one particular script much cleaner and simpler.

What The Script Does

We write our blog in Markdown, and we try to use semantic linefeeds. That means putting each sentence on its own line. Semantic linefeeds make it a lot easier to discuss Markdown documents in GitHub comments, because you can attach comments to individual sentences.

But it adds mental overhead if you’re remembering to write your Markdown using semantic linefeeds, and you’re not used to it. And, when we’re writing our blog posts, it’s more important for us to focus on writing a good post. It’s easy to just write a script which converts non-semantic linefeeds into semantic linefeeds, and run that script after writing the post.

That’s what this script does. It doesn’t handle every edge case — that could lead to writing a whole Markdown parser — but instead just runs a regular expression.

The Original Version

Here’s the ugly original, which I wrote in CoffeeScript. I’m going to skip some plumbing and show you the important part:

class BlogPost

  constructor: (cli) ->
    @usage = cli.getUsage({
      header: "Convert a blog post to semantic linefeeds."
    })

    {@input, @help} = cli.parse()

  convert: () ->

    if @help || !@input
      console.log @usage

    else
      filteredVersion = ""

      lineReader.eachLine(@input, (line, last) ->

        sentenceSeparators = /([\.\?\!]) /

        if line.match(sentenceSeparators)
          line.split(sentenceSeparators).forEach (sentence) ->
            process.stdout.write sentence
            if sentence.match /[\.\?\!]/
              process.stdout.write "\n"

        else
          console.log line)

new BlogPost(cli).convert()

It’s not pretty, and you can probably tell I’ve written far too much Ruby in my life. But it gets the job done 90% of the time, and it’s not intended to do much more than that.

To be specific, this code starts off with a few lines that set it up with an @input filename, and a @usage message for error handling.

  constructor: (cli) ->
    @usage = cli.getUsage({
      header: "Convert a blog post to semantic linefeeds."
    })

    {@input, @help} = cli.parse()

It then reads each line of its input file;

      lineReader.eachLine(@input, (line, last) ->

looks for punctuation which marks the end of sentences;

        sentenceSeparators = /([\.\?\!]) /

        if line.match(sentenceSeparators)

splits the lines on those punctuation marks if they’re there;

          line.split(sentenceSeparators).forEach (sentence) ->

writes out individual sentences;

            process.stdout.write sentence

and then adds newlines after the punctuation.

            if sentence.match /[\.\?\!]/
              process.stdout.write "\n"

In addition to using overly convoluted logic, it uses the same regular expression twice (sentenceSeparators), and then uses an essentially identical variant for a third time.

On top of all that, although you can’t see it in the above excerpt, this code also handles writing to a file through a bash wrapper script. As you might imagine, I was pressed for time when I first wrote this thing.

The Fairmont Version

The Fairmont version is a lot shorter and simpler.

{createReadStream, createWriteStream, renameSync} = require "fs"
{resolve} = require "path"

{go, stream, lines, pump, map, curry, abort} = require "fairmont"
tmp = require "tmp"

# I left out the stuff which sets up error reporting and
# handles command-line arguments, but it would go here...

paths =
  in: resolve options.input
  out: tmp.fileSync().name

replace = curry (before, after, string) -> string.replace before, after

go [
  stream createReadStream paths.in
  map (buffer) -> buffer.toString()
  map replace /([\.\?\!]) /g, "$1\n"
  pump createWriteStream paths.out
]
.then -> renameSync paths.out, paths.in

First, we require some stuff from the Node standard library, tmp, and Fairmont.

Next, we get filenames for both our in file and an out tempfile:

paths =
  in: resolve options.input
  out: tmp.fileSync().name

Then we set up a replace method, which is just a curried version of String.replace:

replace = curry (before, after, string) -> string.replace before, after

Currying allows us to bind the before and after arguments to replace without calling it. This will give us an intermediate function that simply takes a single string argument.

It makes the next block of code possible:

go [
  stream createReadStream paths.in
  map (buffer) -> buffer.toString()
  map replace /([\.\?\!]) /g, "$1\n"
  pump createWriteStream paths.out
]
.then -> renameSync paths.out, paths.in

go is a convenience method which just kicks things off. This code turns a file into a stream;

  stream createReadStream paths.in

converts each buffer in that stream to a string;

  map (buffer) -> buffer.toString()

replaces sentence-ending punctuation with newlines;

  map replace /([\.\?\!]) /g, "$1\n"

writes the altered text to the out tempfile via another stream;

  pump createWriteStream paths.out

and finally replaces the input file with the tempfile.

.then -> renameSync paths.out, paths.in

As you can see, it’s shorter and sweeter than its OOP predecessor. You need to understand streams and functional programming, but if you learn about these things, you get terser, simpler code.