From f45144f4dff7005bcfe58f1d3e545a28ec1b2bf0 Mon Sep 17 00:00:00 2001 From: David Chambers Date: Sun, 27 May 2012 12:31:48 -0700 Subject: [PATCH] grant doctests access to surrounding scope --- README.markdown | 139 ++++++++++++++++++++++++++++++++-------- doctest.coffee | 92 +++++++++++++++++---------- doctest.js | 165 ++++++++++++++++++++++++++++-------------------- 3 files changed, 267 insertions(+), 129 deletions(-) diff --git a/README.markdown b/README.markdown index 5bcbe333..5c50485e 100644 --- a/README.markdown +++ b/README.markdown @@ -1,22 +1,24 @@ # doctest -A quick and (very) dirty implementation of JavaScript [doctests][1], for -those rare occasions when tests of this nature are actually appropriate. - - Math.product = function () { - // > Math.product(3, 4, 5) - // 60 - // > Math.product(2, "ten") - // NaN - // > Math.product(6) - // undefined - var idx = arguments.length - if (idx) { - var product = arguments[0] - while (--idx) product *= arguments[idx] - return product - } - } +A quick and (very) dirty implementation of JavaScript [doctests][1] (which are +occasionally very useful). + +```javascript +// > Math.product(3, 4, 5) +// 60 +// > Math.product(2, "ten") +// NaN +// > Math.product(6) +// undefined +Math.product = function() { + var idx = arguments.length + if (idx) { + var product = arguments[0] + while (--idx) product *= arguments[idx] + return product + } +} +``` To run doctests, pass `doctest` paths to one or more "modules" to be tested. Each path should be one of the following: @@ -29,7 +31,7 @@ This can easily be done from a browser console: > doctest("./math-extensions.js") retrieving /scripts/./math-extensions.js... - running doctests in /scripts/./math-extensions.js... + running doctests in math-extensions.js... ..x expected undefined on line 7 (got 6) @@ -39,23 +41,104 @@ Oops. Looks like we have a bug. It's easy to indicate that an error (of a particular kind) is expected: - // > (var x = 5) - // SyntaxError // > null.length // TypeError ### Scoping -All expressions are eval'd in the global scope, so the following will leave -a `user` property attached to the global object: +doctest doesn't use a parser; it treats JavaScript files as lines of text. +In spite of this, each doctest has access to variables in its scope chain: - // > user = {first_name: "Sheldon", last_name: "Cooper"} - // > user.first_name + " " + user.last_name - // "Sheldon Cooper" +```javascript +!function() { -This shouldn't be a problem in practice (and it's actually rather useful in -some cases), but it's worth bearing in mind that variables are available to -all subsequent tests. + var x = 6 + var y = 7 + // > x * y + // 42 + +}() +``` + +It's even possible to reference variables that have not yet been defined: + +```javascript +!function() { + + // > toUsername("Jesper Nøhr") + // "jespernhr" + // > toUsername(15 * 15) + // "225" + var toUsername = function(text) { + return ('' + text).replace(/\W/g, '').toLowerCase() + } + +}() +``` + +It's important to be familiar with the hack doctest employs to achieve this, +since it places constraints on where doctests may appear in a file. + +Once doctest has retrieved a file via XMLHttpRequest, three things happen: + +1. Input lines (single-line comments beginning with ">") and associated + output lines are rewritten as executable code (calls to `doctest.input` + and `doctest.output`, specifically). + +2. The rewritten file is eval'd. + +3. `doctest.run` is called, invoking functions queued in the previous step. + +In the first step, the code example above would be rewritten as: + +```javascript +!function() { + + doctest.input(function() { return toUsername("Jesper Nøhr") }) + doctest.output(4, function() { return "jespernhr" }) + doctest.input(function() { return 15 * 15 }) + doctest.output(6, function() { return "225" }) + var toUsername = function(text) { + return ('' + text).replace(/\W/g, '').toLowerCase() + } + +}() +``` + +The naive nature of the rewriter prevents this from working: + +```javascript +MyApp.utils = { + // MyApp.utils.foo() + // "foo" + foo: function() { + return 'foo' + }, + // MyApp.utils.bar() + // "bar" + bar: function() { + return 'bar' + } +} +``` + +The code could be restructured to accommodate the rewriter: + +```javascript +MyApp.utils = {} + +// MyApp.utils.foo() +// "foo" +MyApp.utils.foo = function() { + return 'foo' +} + +// MyApp.utils.bar() +// "bar" +MyApp.utils.bar = function() { + return 'bar' +} +``` ### Dependencies diff --git a/doctest.coffee b/doctest.coffee index 278a89f0..4cea6615 100644 --- a/doctest.coffee +++ b/doctest.coffee @@ -10,9 +10,44 @@ ### -window.doctest = (urls...) -> - fetch url for url in urls - return +doctest = (urls...) -> _.each urls, fetch + +doctest.version = '0.2.0' + +doctest.queue = [] + +doctest.input = (fn) -> + @queue.push fn + +doctest.output = (num, fn) -> + fn.line = num + @queue.push fn + +doctest.run = -> + results = []; input = null + + while fn = @queue.shift() + unless num = fn.line + input?() + input = fn + continue + + expected = fn() + results.push try + actual = input() + [_.isEqual(actual, expected), q(expected), q(actual), num] + catch error + actual = error.constructor + [actual is expected, expected?.name or q(expected), error.name, num] + input = null + + @complete results + +doctest.complete = (results) -> + console.log ((if pass then '.' else 'x') for [pass] in results).join '' + for [pass, expected, actual, num] in (r for r in results when not r[0]) + console.warn "expected #{expected} on line #{num} (got #{actual})" + fetch = (url) -> # Support relative paths; e.g. `doctest("./foo.js")`. @@ -21,39 +56,32 @@ fetch = (url) -> console.log "retrieving #{url}..." jQuery.ajax url, dataType: 'text', success: (text) -> - results = test text - console.log "running doctests in #{url}..." - console.log ((if pass then '.' else 'x') for [pass] in results).join '' - for [pass, expected, actual, num] in (r for r in results when not r[0]) - console.warn "expected #{expected} on line #{num} (got #{actual})" + console.log "running doctests in #{/[^/]+$/.exec url}..." + eval rewrite text + doctest.run() -window.doctest.version = '0.1.3' -commented_lines = (text) -> - lines = [] +rewrite = (text) -> + lines = []; expr = '' for line, idx in text.split /\r?\n|\r/ if match = /^[ \t]*\/\/[ \t]*(.+)/.exec line - lines.push [idx + 1, match[1]] - lines - -test = (text) -> - results = []; expr = '' - for [num, line] in commented_lines text - if match = /^>(.*)/.exec line - eval expr - expr = match[1] - else if match = /^[.](.*)/.exec line - expr += '\n' + match[1] - else if expr - expected = eval line - results.push try - actual = eval expr - [_.isEqual(actual, expected), q(expected), q(actual), num] - catch error - actual = error.constructor - [actual is expected, expected?.name or q(expected), error.name, num] - expr = '' - results + comment = match[1] + if match = /^>(.*)/.exec comment + lines.push "doctest.input(function(){return #{expr}})" if expr + expr = match[1] + else if match = /^[.](.*)/.exec comment + expr += '\n' + match[1] + else if expr + lines.push "doctest.input(function(){return #{expr}})" + lines.push "doctest.output(#{idx + 1},function(){return #{comment}})" + expr = '' + else + lines.push line + lines.join '\n' + q = (object) -> if typeof object is 'string' then "\"#{object}\"" else object + + +window.doctest = doctest diff --git a/doctest.js b/doctest.js index ecd43177..ab4db0de 100644 --- a/doctest.js +++ b/doctest.js @@ -13,16 +13,83 @@ (function() { - var commented_lines, fetch, q, test, + var doctest, fetch, q, rewrite, __slice = [].slice; - window.doctest = function() { - var url, urls, _i, _len; + doctest = function() { + var urls; urls = 1 <= arguments.length ? __slice.call(arguments, 0) : []; - for (_i = 0, _len = urls.length; _i < _len; _i++) { - url = urls[_i]; - fetch(url); + return _.each(urls, fetch); + }; + + doctest.version = '0.2.0'; + + doctest.queue = []; + + doctest.input = function(fn) { + return this.queue.push(fn); + }; + + doctest.output = function(num, fn) { + fn.line = num; + return this.queue.push(fn); + }; + + doctest.run = function() { + var actual, expected, fn, input, num, results; + results = []; + input = null; + while (fn = this.queue.shift()) { + if (!(num = fn.line)) { + if (typeof input === "function") { + input(); + } + input = fn; + continue; + } + expected = fn(); + results.push((function() { + try { + actual = input(); + return [_.isEqual(actual, expected), q(expected), q(actual), num]; + } catch (error) { + actual = error.constructor; + return [actual === expected, (expected != null ? expected.name : void 0) || q(expected), error.name, num]; + } + })()); + input = null; } + return this.complete(results); + }; + + doctest.complete = function(results) { + var actual, expected, num, pass, r, _i, _len, _ref, _ref1, _results; + console.log(((function() { + var _i, _len, _results; + _results = []; + for (_i = 0, _len = results.length; _i < _len; _i++) { + pass = results[_i][0]; + _results.push(pass ? '.' : 'x'); + } + return _results; + })()).join('')); + _ref = (function() { + var _j, _len, _results1; + _results1 = []; + for (_j = 0, _len = results.length; _j < _len; _j++) { + r = results[_j]; + if (!r[0]) { + _results1.push(r); + } + } + return _results1; + })(); + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + _ref1 = _ref[_i], pass = _ref1[0], expected = _ref1[1], actual = _ref1[2], num = _ref1[3]; + _results.push(console.warn("expected " + expected + " on line " + num + " (got " + actual + ")")); + } + return _results; }; fetch = function(url) { @@ -34,81 +101,39 @@ return jQuery.ajax(url, { dataType: 'text', success: function(text) { - var actual, expected, num, pass, r, results, _i, _len, _ref, _ref1, _results; - results = test(text); - console.log("running doctests in " + url + "..."); - console.log(((function() { - var _i, _len, _results; - _results = []; - for (_i = 0, _len = results.length; _i < _len; _i++) { - pass = results[_i][0]; - _results.push(pass ? '.' : 'x'); - } - return _results; - })()).join('')); - _ref = (function() { - var _j, _len, _results1; - _results1 = []; - for (_j = 0, _len = results.length; _j < _len; _j++) { - r = results[_j]; - if (!r[0]) { - _results1.push(r); - } - } - return _results1; - })(); - _results = []; - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - _ref1 = _ref[_i], pass = _ref1[0], expected = _ref1[1], actual = _ref1[2], num = _ref1[3]; - _results.push(console.warn("expected " + expected + " on line " + num + " (got " + actual + ")")); - } - return _results; + console.log("running doctests in " + (/[^/]+$/.exec(url)) + "..."); + eval(rewrite(text)); + return doctest.run(); } }); }; - window.doctest.version = '0.1.3'; - - commented_lines = function(text) { - var idx, line, lines, match, _i, _len, _ref; + rewrite = function(text) { + var comment, expr, idx, line, lines, match, _i, _len, _ref; lines = []; + expr = ''; _ref = text.split(/\r?\n|\r/); for (idx = _i = 0, _len = _ref.length; _i < _len; idx = ++_i) { line = _ref[idx]; if (match = /^[ \t]*\/\/[ \t]*(.+)/.exec(line)) { - lines.push([idx + 1, match[1]]); - } - } - return lines; - }; - - test = function(text) { - var actual, expected, expr, line, match, num, results, _i, _len, _ref, _ref1; - results = []; - expr = ''; - _ref = commented_lines(text); - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - _ref1 = _ref[_i], num = _ref1[0], line = _ref1[1]; - if (match = /^>(.*)/.exec(line)) { - eval(expr); - expr = match[1]; - } else if (match = /^[.](.*)/.exec(line)) { - expr += '\n' + match[1]; - } else if (expr) { - expected = eval(line); - results.push((function() { - try { - actual = eval(expr); - return [_.isEqual(actual, expected), q(expected), q(actual), num]; - } catch (error) { - actual = error.constructor; - return [actual === expected, (expected != null ? expected.name : void 0) || q(expected), error.name, num]; + comment = match[1]; + if (match = /^>(.*)/.exec(comment)) { + if (expr) { + lines.push("doctest.input(function(){return " + expr + "})"); } - })()); - expr = ''; + expr = match[1]; + } else if (match = /^[.](.*)/.exec(comment)) { + expr += '\n' + match[1]; + } else if (expr) { + lines.push("doctest.input(function(){return " + expr + "})"); + lines.push("doctest.output(" + (idx + 1) + ",function(){return " + comment + "})"); + expr = ''; + } + } else { + lines.push(line); } } - return results; + return lines.join('\n'); }; q = function(object) { @@ -119,4 +144,6 @@ } }; + window.doctest = doctest; + }).call(this);