From 21b602ef663a6041912c6eb56915ac9ba2eafead Mon Sep 17 00:00:00 2001 From: "David Humphrey (:humph) david.humphrey@senecacollege.ca" Date: Fri, 28 Nov 2014 12:48:10 -0500 Subject: [PATCH] Fix #329 - Implement Shell.find() --- README.md | 61 +++++++++ package.json | 3 +- src/path.js | 23 +++- src/shell/shell.js | 117 ++++++++++++++++ tests/index.js | 1 + tests/spec/path-resolution.spec.js | 18 +++ tests/spec/shell/find.spec.js | 206 +++++++++++++++++++++++++++++ 7 files changed, 424 insertions(+), 5 deletions(-) create mode 100644 tests/spec/shell/find.spec.js diff --git a/README.md b/README.md index 1e21803..90f78dd 100644 --- a/README.md +++ b/README.md @@ -1171,6 +1171,7 @@ var sh = new fs.Shell(); * [sh.cd(path, callback)](#cd) * [sh.pwd()](#pwd) +* [sh.find(dir, [options], callback)](#find) * [sh.ls(dir, [options], callback)](#ls) * [sh.exec(path, [args], callback)](#exec) * [sh.touch(path, [options], callback)](#touch) @@ -1199,6 +1200,66 @@ sh.cd('/dir1', function(err) { Returns the shell's current working directory. See [sh.cd()](#cd). +#### sh.find(dir, [options], callback) + +Recursively walk a directory tree, reporting back all paths that were +found along the way. Asynchronous [find(1)](http://pubs.opengroup.org/onlinepubs/9699919799/utilities/find.html +) +If given no options, `find` walks the given dir path +and the callback gives `function(err, found)`, where `found` is an array of +all paths discovered during a depth-first walk. + +Valid options include a `regex` for pattern matching paths, allowing paths +to be ignored (e.g., ```regex: /\.bak$/``` to find all `.bak` files). You can +also use `name` and `path` to provide a [match pattern](https://github.com/isaacs/minimatch) for the basename and +dirname respectively (e.g., `{name: '*.js'}` to find all JavaScript files or +`{path: '*-modules'}` to only look in folders named `base-modules`, `foo-modules`, etc.). +Finally, you can also provide an `exec` function of the form `function(path, next)` where +`path` is the current path that was found and matches any provided `regex` +(NOTE: dir paths have an '/' appended), and `next` is a callback to call +when you are done processing the path. + +Example: + +```javascript +function processPath(path, next) { + // Process the path somehow, in this case we print it. + // Dir paths end with / + if(path.endsWith('/')) { + console.log('Found dir: ' + path); + } else { + console.log('Found file: ' + path); + } + + // All done, let the process continue by invoking second arg: + next(); +} + +// Get every path (NOTE: no name or regex provided) below the root, depth first +sh.find('/', {exec: processPath}, function(err, found) { + /* find command is finished, `found` contains the flattened list as an Array */ +}); + +// Find all files that look like map201.jpg, map202.jpg in the /data dir +sh.find('/data', {regex: /map20\d\.jpg$/, exec: processPath}, function(err) { + /* find command is finished */ +}); + +// Find and delete all *.bak files under /app/user +sh.find('/app/user', { + name: '*.bak', + exec: function(path, next) { + sh.rm(path, next); + } +}, function callback(err, found) { + if(err) throw err; + + if(found.length) { + console.log('Deleted the following ' + found.length + ' files: ', found); + } +}); +``` + #### sh.ls(dir, [options], callback) Get the listing of a directory, returning an array of directory entries diff --git a/package.json b/package.json index bc58ef8..e84534f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ }, "dependencies": { "bower": "~1.3.8", - "base64-arraybuffer": "^0.1.2" + "base64-arraybuffer": "^0.1.2", + "minimatch": "^1.0.0" }, "devDependencies": { "chai": "~1.9.1", diff --git a/src/path.js b/src/path.js index a75659a..a3e0f25 100644 --- a/src/path.js +++ b/src/path.js @@ -66,7 +66,7 @@ function resolve() { resolvedAbsolute = false; for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) { - // XXXidbfs: we don't have process.cwd() so we use '/' as a fallback + // XXXfiler: we don't have process.cwd() so we use '/' as a fallback var path = (i >= 0) ? arguments[i] : '/'; // Skip empty and invalid entries @@ -184,7 +184,7 @@ function basename(path, ext) { if (ext && f.substr(-1 * ext.length) === ext) { f = f.substr(0, f.length - ext.length); } - // XXXidbfs: node.js just does `return f` + // XXXfiler: node.js just does `return f` return f === "" ? "/" : f; } @@ -206,7 +206,19 @@ function isNull(path) { return false; } -// XXXidbfs: we don't support path.exists() or path.existsSync(), which +// Make sure we don't double-add a trailing slash (e.g., '/' -> '//') +function addTrailing(path) { + return path.replace(/\/*$/, '/'); +} + +// Deal with multiple slashes at the end, one, or none +// and make sure we don't return the empty string. +function removeTrailing(path) { + path = path.replace(/\/*$/, ''); + return path === '' ? '/' : path; +} + +// XXXfiler: we don't support path.exists() or path.existsSync(), which // are deprecated, and need a FileSystem instance to work. Use fs.stat(). module.exports = { @@ -220,5 +232,8 @@ module.exports = { basename: basename, extname: extname, isAbsolute: isAbsolute, - isNull: isNull + isNull: isNull, + // Non-node but useful... + addTrailing: addTrailing, + removeTrailing: removeTrailing }; diff --git a/src/shell/shell.js b/src/shell/shell.js index 3fd1a58..000fc02 100644 --- a/src/shell/shell.js +++ b/src/shell/shell.js @@ -3,6 +3,7 @@ var Errors = require('../errors.js'); var Environment = require('./environment.js'); var async = require('../../lib/async.js'); var Encoding = require('../encoding.js'); +var minimatch = require('minimatch'); function Shell(fs, options) { options = options || {}; @@ -426,4 +427,120 @@ Shell.prototype.mkdirp = function(path, callback) { _mkdirp(path, callback); }; +/** + * Recursively walk a directory tree, reporting back all paths + * that were found along the way. The `path` must be a dir. + * Valid options include a `regex` for pattern matching paths + * and an `exec` function of the form `function(path, next)` where + * `path` is the current path that was found (dir paths have an '/' + * appended) and `next` is a callback to call when done processing + * the current path, passing any error object back as the first argument. + * `find` returns a flat array of absolute paths for all matching/found + * paths as the final argument to the callback. + */ + Shell.prototype.find = function(path, options, callback) { + var sh = this; + var fs = sh.fs; + if(typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + callback = callback || function(){}; + + var exec = options.exec || function(path, next) { next(); }; + var found = []; + + if(!path) { + callback(new Errors.EINVAL('Missing path argument')); + return; + } + + function processPath(path, callback) { + exec(path, function(err) { + if(err) { + callback(err); + return; + } + + found.push(path); + callback(); + }); + } + + function maybeProcessPath(path, callback) { + // Test the path against the user's regex, name, path primaries (if any) + // and remove any trailing slashes added previously. + var rawPath = Path.removeTrailing(path); + + // Check entire path against provided regex, if any + if(options.regex && !options.regex.test(rawPath)) { + callback(); + return; + } + + // Check basename for matches against name primary, if any + if(options.name && !minimatch(Path.basename(rawPath), options.name)) { + callback(); + return; + } + + // Check dirname for matches against path primary, if any + if(options.path && !minimatch(Path.dirname(rawPath), options.path)) { + callback(); + return; + } + + processPath(path, callback); + } + + function walk(path, callback) { + path = Path.resolve(sh.pwd(), path); + + // The path is either a file or dir, and instead of doing + // a stat() to determine it first, we just try to readdir() + // and it will either work or not, and we handle the non-dir error. + fs.readdir(path, function(err, entries) { + if(err) { + if(err.code === 'ENOTDIR' /* file case, ignore error */) { + maybeProcessPath(path, callback); + } else { + callback(err); + } + return; + } + + // Path is really a dir, add a trailing / and report it found + maybeProcessPath(Path.addTrailing(path), function(err) { + if(err) { + callback(err); + return; + } + + entries = entries.map(function(entry) { + return Path.join(path, entry); + }); + + async.eachSeries(entries, walk, function(err) { + callback(err, found); + }); + }); + }); + } + + // Make sure we are starting with a dir path + fs.stat(path, function(err, stats) { + if(err) { + callback(err); + return; + } + if(!stats.isDirectory()) { + callback(new Errors.ENOTDIR(null, path)); + return; + } + + walk(path, callback); + }); +}; + module.exports = Shell; diff --git a/tests/index.js b/tests/index.js index e997797..7e9d0ec 100644 --- a/tests/index.js +++ b/tests/index.js @@ -55,6 +55,7 @@ require("./spec/shell/ls.spec"); require("./spec/shell/rm.spec"); require("./spec/shell/env.spec"); require("./spec/shell/mkdirp.spec"); +require("./spec/shell/find.spec"); // Ported node.js tests (filenames match names in https://github.com/joyent/node/tree/master/test) require("./spec/node-js/simple/test-fs-mkdir"); diff --git a/tests/spec/path-resolution.spec.js b/tests/spec/path-resolution.spec.js index 77d7f3f..b2efc3f 100644 --- a/tests/spec/path-resolution.spec.js +++ b/tests/spec/path-resolution.spec.js @@ -223,4 +223,22 @@ describe('path resolution', function() { }); }); }); + + it('should properly add trailing slashes with Path.addTrailing()', function() { + var Path = Filer.Path; + expect(Path.addTrailing('/')).to.equal('/'); + expect(Path.addTrailing('/////')).to.equal('/'); + expect(Path.addTrailing('.')).to.equal('./'); + expect(Path.addTrailing('/dir')).to.equal('/dir/'); + expect(Path.addTrailing('/dir/')).to.equal('/dir/'); + }); + + it('should properly remove trailing slashes with Path.removeTrailing()', function() { + var Path = Filer.Path; + expect(Path.removeTrailing('/')).to.equal('/'); + expect(Path.removeTrailing('/////')).to.equal('/'); + expect(Path.removeTrailing('./')).to.equal('.'); + expect(Path.removeTrailing('/dir/')).to.equal('/dir'); + expect(Path.removeTrailing('/dir//')).to.equal('/dir'); + }); }); diff --git a/tests/spec/shell/find.spec.js b/tests/spec/shell/find.spec.js new file mode 100644 index 0000000..3d6a3ea --- /dev/null +++ b/tests/spec/shell/find.spec.js @@ -0,0 +1,206 @@ +var Filer = require('../../..'); +var util = require('../../lib/test-utils.js'); +var expect = require('chai').expect; + +describe('FileSystemShell.find', function() { + beforeEach(function(done) { + util.setup(function() { + var fs = util.fs(); + /** + * Create a basic fs layout for each test: + * + * / + * --file1 + * --file2 + * --dir1/ + * --file3 + * --subdir1/ + * --dir2/ + * --file4 + */ + fs.writeFile('/file1', 'file1', function(err) { + if(err) throw err; + + fs.writeFile('/file2', 'file2', function(err) { + if(err) throw err; + + fs.mkdir('/dir1', function(err) { + if(err) throw err; + + fs.writeFile('/dir1/file3', 'file3', function(err) { + if(err) throw err; + + fs.mkdir('/dir1/subdir1', function(err) { + if(err) throw err; + + fs.mkdir('/dir2', function(err) { + if(err) throw err; + + fs.writeFile('/dir2/file4', 'file4', function(err) { + if(err) throw err; + + done(); + }); + }); + }); + }); + }); + }); + }); + }); + }); + afterEach(util.cleanup); + + it('should be a function', function() { + var shell = util.shell(); + expect(shell.find).to.be.a('function'); + }); + + it('should fail when path does not exist', function(done) { + var shell = util.shell(); + + shell.find('/no-such-folder', function(err, found) { + expect(err).to.exist; + expect(err.code).to.equal('ENOENT'); + expect(found).not.to.exist; + done(); + }); + }); + + it('should fail when path exists but is non-dir', function(done) { + var shell = util.shell(); + + shell.find('/file1', function(err, found) { + expect(err).to.exist; + expect(err.code).to.equal('ENOTDIR'); + expect(found).not.to.exist; + done(); + }); + }); + + it('should find all paths in the filesystem with no options', function(done) { + var shell = util.shell(); + + shell.find('/', function(err, found) { + expect(err).not.to.exist; + + var expected = [ + '/', + '/file1', + '/file2', + '/dir1/', + '/dir1/file3', + '/dir1/subdir1/', + '/dir2/', + '/dir2/file4' + ]; + expect(found).to.deep.equal(expected); + done(); + }); + }); + + it('should get same paths in exec as are found when complete', function(done) { + var shell = util.shell(); + var pathsSeen = []; + + function processPath(path, next) { + pathsSeen.push(path); + next(); + } + + shell.find('/', {exec: processPath}, function(err, found) { + expect(err).not.to.exist; + + expect(found).to.deep.equal(pathsSeen); + done(); + }); + }); + + it('should return only paths that match a regex pattern', function(done) { + var shell = util.shell(); + + shell.find('/', {regex: /file\d$/}, function(err, found) { + expect(err).not.to.exist; + + var expected = [ + '/file1', + '/file2', + '/dir1/file3', + '/dir2/file4' + ]; + expect(found).to.deep.equal(expected); + done(); + }); + }); + + it('should append a / to the end of a dir path', function(done) { + var shell = util.shell(); + var dirsSeen = 0; + + function endsWith(str, suffix) { + var lastIndex = str.lastIndexOf(suffix); + return (lastIndex !== -1) && (lastIndex + suffix.length === str.length); + } + + function processPath(path, next) { + expect(endsWith(path, '/')).to.be.true; + dirsSeen++; + next(); + } + + shell.find('/', {regex: /dir\d$/, exec: processPath}, function(err) { + expect(err).not.to.exist; + expect(dirsSeen).to.equal(3); + done(); + }); + }); + + it('should only look below the specified dir path', function(done) { + var shell = util.shell(); + + shell.find('/dir1', function(err, found) { + expect(err).not.to.exist; + + var expected = [ + '/dir1/', + '/dir1/file3', + '/dir1/subdir1/' + ]; + expect(found).to.deep.equal(expected); + done(); + }); + }); + + it('should allow using options.name to match basename with a pattern', function(done) { + var shell = util.shell(); + + shell.find('/', {name: 'file*'}, function(err, found) { + expect(err).not.to.exist; + + var expected = [ + '/file1', + '/file2', + '/dir1/file3', + '/dir2/file4' + ]; + expect(found).to.deep.equal(expected); + done(); + }); + }); + + it('should allow using options.path to match dirname with a pattern', function(done) { + var shell = util.shell(); + + shell.find('/', {name: '*ir1*'}, function(err, found) { + expect(err).not.to.exist; + + var expected = [ + '/dir1/', + '/dir1/subdir1/' + ]; + expect(found).to.deep.equal(expected); + done(); + }); + }); + +});