Skip to content

Commit

Permalink
grant doctests access to surrounding scope
Browse files Browse the repository at this point in the history
  • Loading branch information
davidchambers committed May 27, 2012
1 parent 7f229c4 commit f45144f
Show file tree
Hide file tree
Showing 3 changed files with 267 additions and 129 deletions.
139 changes: 111 additions & 28 deletions README.markdown
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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)

Expand All @@ -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

Expand Down
92 changes: 60 additions & 32 deletions doctest.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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")`.
Expand All @@ -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
Loading

0 comments on commit f45144f

Please sign in to comment.