I have a small CLI utility for outputting source files centered in the terminal. The module center-code is very useful during demos or presentations - the centered code looks great projected on the projected screen when the font is large.
To center text, I grab the terminal resolution using the following NodeJS code
1 | // somewhere in https://github.com/bahmutov/center-code/blob/master/index.js |
The code works and can be unit tested, for example I wrote the following
1 | it('works under Node', function () { |
You can find this unit test in the spec/misc-spec.js.
The tests pass when I run Mocha from the command line.
$ npm run test
> [email protected] test /git/center-code
> mocha spec/*-spec.js
widest(lines)
✓ finds longest line length
blanks(n)
✓ forms empty string with 5 spaces
terminalSize()
✓ works under Node
3 passing (27ms)
All was good, I was happy and tried to commit this code. Before each commit, I run npm test
and other
commands using a pre-git hook
1 | "pre-commit": [ |
So I was trying to commit, but the hook failed!
$ g done "adding unit test for grabbing terminal resolution"
executing task "npm" with args "test"
> [email protected] test /git/center-code
> mocha spec/*-spec.js
widest(lines)
✓ finds longest line length
blanks(n)
✓ forms empty string with 5 spaces
terminalSize()
1) works under Node (dirty)
2 passing (30ms)
1 failing
1) terminalSize() works under Node (dirty):
Error: got resolution object undefined
What is going on? Turns out when the pre-commit hook runs, it spawns a shell that is not a human terminal. Thus it will not have columns or rows properties. Our code thus depends on the outside state, and we just got burnt.
Mock dependency injection solution
We can work around it the problem by injecting the output stream variable dynamically, and using a mock
value inside unit tests. Note that the function terminalSize
became pure - it only uses its arguments
to compute the result.
1 | function terminalSize(outputStream) { |
The unit test sends a mock output stream into the function.
1 | it('works under Node', function () { |
We can even confirm the output width
and height
numbers by comparing them to the
fake input terminal's dimensions.
Monad solution
There is another way to solve the "dangerous" function problem. We can postpone the execution
of the function that needs global variables until the very last moment. For example, our dangerous
function is the one that returns the process
object. Let us write it.
1 | // impure function - danger: using and testing requires care |
The function that computes the terminal resolution instead of using the argument will return an IO monad object, composing the unsafe getProcess function with getting the columns and rows.
1 | function getProcess() { return process; } |
Notice that the function terminalSize
is pure - it never accesses anything outside its arguments.
Yes it has a reference to the dangerous getProcess
stored inside the IO
object, but it is never executed!
The IO
constructor is a small utility function taken from Chapter 8 of the
Mostly adequate guide to FP in JavaScript. All it does is to
- Holds reference to a (possibly dangerous) function inside.
- Has
.map
method to create a composition of the function inside with a given callback function.
The composition using .map
returns a new IO
instance, building a chain of functions that are NOT
executed until we call the inner function. Here is the code for IO
1 | var R = require('ramda'); // only need R.compose |
By holding the unsafe function, and later, a composition of unsafe function with other functions inside
an object, and providing a general .map
method, we got ourselves a Monad. But the name does not matter -
only using it matters. For example we can do something like this to delay calling an unsafe function
until we are ready
1 | var ioGlobal = new IO(function unsafeGlobalAccess() { |
Pretty cool!
After writing IO
monad code, let us rewrite our imperative code that computes the padding
for the given text to be in the terminal's center using an IO monad chain. Our original code was
1 | function centerText(options, source) { |
Our modified code is almost the same.
1 | function centerText(options, source) { |
Notice that the function centerText
is pure, and nothing bad happens until
the function inside the monad is executed. Only then the entire pipeline starts: first
the unsafe getProcess
runs, then the terminal size is computed, then the size
is passed into the center
callback.
Now to our unit test, it is changing a little to set mock property before running the monad chain.
1 | it('works with a monad', function () { |
We could have restored the original terminal dimensions, but I skipped this for clarity.
To learn more about monads and other functional goodness, read the Mostly adequate guide to FP (in javascript).