Code Review: Search
Code Review is a series on DailyJS where I take a look at an open source project to see how it’s built. Along the way we’ll learn patterns and techniques by JavaScript masters. If you’re looking for tips to write better apps, or just want to see how they’re structured in established projects, then this is the tutorial series for you.
Search by TJ Holowaychuk is a small ack inspired utility for searching source code. I picked this project out in particular because it showcases TJ’s nifty Commander.js library and demonstrates how easy it is to build fast command line utilities with Node.
Installation and Usage
Search can be installed with npm install -g search. This clashes with my Sphinx binaries, so I installed it in ~/ and set up an alias in my shell.
To use it, navigate to a directory with lots of code and run search text, where text is a case insensitive regular expression.
Structure
Like TJ’s other projects, this is distributed with a README, Makefile, history, and all of the code is in bin/search. I’d argue against putting all of the code in bin/search so I could require core modules elsewhere (potentially making testing easier), but the binary itself could be tested as well.
Set Up
As I mentioned, this project uses Commander.js, which makes setting everything up a breeze:
var program = require('commander')
, path = require('path')
, join = path.join
, fs = require('fs');
// options
program
.version('0.0.4')
.usage('[options] <query> [path ...]')
.option('-H, --hidden', 'search hidden files and directories')
.parse(process.argv);
// no args
if (!program.args.length) {
process.stdout.write(program.helpInformation());
process.exit(0);
}
The chainable API allows everything that describes the program to be described in a concise manner.
Next, the paths and query are processed. The regular expression is instantiated once for performance reasons:
var query = program.args.shift()
, paths = program.args
, pending = paths.length
, re = new RegExp('(' + query + ')', 'ig');
Searching
The main searching code is similar to code I’ve written a few times for my Node apps, which is one of the reasons I picked this for a code review. It uses Node’s asynchronous file system APIs to recursively walk over each path and their children. However, the slight twist here is TJ uses Array.prototype.(forEach|filter|map) rather than for loops. A lot of people use for loops over iterators for performance reasons, reducing scoping complexity, or browser support. It’s worth considering this counter example in terms of readability.
I’ve added some comments to explain how it works:
function search(path) {
// Does this file exist?
fs.stat(path, function(err, stat){
if (err) throw err;
// Is it a directory?
if (stat.isDirectory()) {
// If it's a directory, remove hidden files using the hidden() function (defined below),
// then generate a list of file names with the current path, then run search() again on the resulting paths
fs.readdir(path, function(err, files){
if (err) throw err;
files.filter(hidden).map(function(file){
return join(path, file);
}).forEach(search);
});
The next part reads through each file and searches each line for the regular expression. Output is printed directly with console.log, and colour codes are inserted to make the matches easier to spot.
} else if (stat.isFile()) {
var lines = [];
fs.readFile(path, 'utf8', function(err, str){
if (err) throw err;
str.split('\n').forEach(function(line, i){
if (!re.test(line)) return;
lines.push([i, line]);
});
if (lines.length) {
console.log('\n \033[36m%s\033[0m', path);
lines.forEach(function(line){
var i = line[0]
, line = line[1];
line = line.replace(re, '\033[37;43m$1\033[0;90m');
console.log(' \033[90m%d: %s\033[0m', i+1, line);
});
}
});
Conclusion
TJ makes writing command line apps look easy. That’s partly because it actually is! The next time you’re itching to solve an interesting console-based problem, try scripting something with Node. There are lots of projects similar to Search that you can reference to get a head start.