Cleaning Up A Trivial Script With Fairmont & FRP
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.