Permalink
Browse files

separate tests & module, update README.md for js/coffee

  • Loading branch information...
1 parent 094243e commit 7d581553e8ddd2cca518df2ea827efea8016b33c Andrew Kassen committed Jun 8, 2012
Showing with 233 additions and 136 deletions.
  1. +14 −12 README.md
  2. +118 −124 docopt.coffee
  3. +101 −0 test_docopt.coffee
View
26 README.md
@@ -3,9 +3,9 @@ TODO: This ruby page should be adopted for CoffeeScript/JavaScript.
`docopt` – command line option parser, that will make you smile
===============================================================================
-Help porting [docopt](http://docopt.org/) to Ruby!
+Help porting [docopt](http://docopt.org/) to CoffeeScript and JavaScript!
-Isn't it awesome how `optparse` and other option parsers generate help and
+Isn't it awesome how Python's `optparse` and other option parsers generate help and
usage-messages based on your code?!
Hell no! You know what's awesome? It's when the option parser *is* generated
@@ -16,7 +16,7 @@ to your code.
Now you can write an awesome, readable, clean, DRY code like *that*:
-```ruby
+```coffeescript
doc = "Usage: example.py [options] <arguments>...
Options:
@@ -39,25 +39,23 @@ Options:
--testsuite=dir run regression tests from dir
--doctest run doctest on myself"
-require 'docopt'
+docopt = require('./docopt').docopt
@keleshev
keleshev Jun 9, 2012

maybe?

{docopt} = require './docopt'
-
-if __FILE__ == $0
+if process.mainModule.id == module.id
options = docopt(doc, '1.0.0') # parse options based on doc above
- puts options.inspect
- puts ARGV.inspect
+ console.log options['--verbose']
end
```
Hell yeah! The option parser is generated based on `doc` string above, that you
pass to the `docopt` function.
-API `require 'docopt'`
+API `require './docopt'`
===============================================================================
-###`options = docopt(doc, version=nil, help=true)`
+###`options = docopt(doc, argv=process.argv[1..], help=true, version=null)`
-`docopt` takes 1 required and 2 optional arguments:
+`docopt` takes 1 required and 3 optional arguments:
- `doc` should be a string that
describes **options** in a human-readable format, that will be parsed to create
@@ -72,6 +70,10 @@ section. Here is a quick example of such a string:
--quiet Print less text.
-o FILE Specify output file [default: ./test.txt].
+- `argv` is an optional argument vector; by default it is the argument vector
+passed to your program (process.argv[1..]). You can supply it with list of
+strings (similar to process.argv) e.g. ['--verbose', '-o', 'hai.txt'].
+
- `help`, by default `true`, specifies whether the parser should automatically
print the usage-message (supplied as `doc`) in case `-h` or `--help` options
are encountered. After showing the usage-message, the program will terminate.
@@ -150,7 +152,7 @@ into the option description, in form `[default: <your-default-value>]`.
--output=FILE Output file [default: test.txt]
--directory=DIR Some directory [default: ./]
-Something missing? Help porting [docopt](http://docopt.org/) to Ruby!
+Something missing? Help porting [docopt](http://docopt.org/) to CoffeeScript/JavaScript!
===============================================================================
Compatibility notice:
View
242 docopt.coffee
@@ -1,118 +1,82 @@
-print = (s) -> console.log(s)
-eq = (a, b) ->
- process.stdout.write if a.toString() == b.toString() then '.' else 'F'
+print = console.log
+class UsageMessageError extends Error
+ constructor: (message) ->
+ print message
+
+class DocoptExit extends Error
+ constructor: (message) ->
+ print message
+ process.exit(1)
+ @usage: ''
# same as Option class in python
-option = (short, long, argcount, value) ->
- short: short ? null
- long: long ? null
- argcount: argcount ? 0
- value: value ? false
- toString: -> "option(#{@short}, #{@long}, #{@argcount}, #{@value})"
-
-
-# same as Option.parse in python
-parse = (description) ->
- # strip whitespace
- description = description.replace(/^\s*|\s*$/g, '')
- # split on first occurence of 2 consecutive spaces (' ')
- [_, options,
- description] = description.match(/(.*?) (.*)/) ? [null, description, '']
- # replace ',' or '=' with ' '
- options = options.replace(/,|=/g, ' ' )
- # set some defaults
- [short, long, argcount, value] = [null, null, 0, false]
- for s in options.split(/\s+/) # split on spaces
- if s[0...2] is '--'
- long = s
- else
- if s[0] is '-'
+class Option
+ constructor: (@short=null, @long=null, @argcount=0, @value=false) ->
+ toString: -> "Option(#{@short}, #{@long}, #{@argcount}, #{@value})"
+ name: -> @long or @short
+ @parse: (description) ->
+ # strip whitespaces
+ description = description.replace(/^\s*|\s*$/g, '')
+ # split on first occurence of 2 consecutive spaces (' ')
+ [_, options,
+ description] = description.match(/(.*?) (.*)/) ? [null, description, '']
+ # replace ',' or '=' with ' '
+ options = options.replace(/,|=/g, ' ' )
+ # set some defaults
+ [short, long, argcount, value] = [null, null, 0, false]
+ for s in options.split(/\s+/) # split on spaces
+ if s[0..1] is '--'
+ long = s
+ else if s[0] is '-'
short = s
else
argcount = 1
- if argcount == 1
- matched = description.match(/\[default: (.*)\]/)
- value = if matched then matched[1] else false
- option(short, long, argcount, value)
-
-eq parse('-h'), option('-h', null)
-eq parse('-h'), option('-h', null)
-eq parse('--help'), option(null, '--help')
-eq parse('-h --help'), option('-h', '--help')
-eq parse('-h, --help'), option('-h', '--help')
-
-eq parse('-h TOPIC'), option('-h', null, 1)
-eq parse('--help TOPIC'), option(null, '--help', 1)
-eq parse('-h TOPIC --help TOPIC'), option('-h', '--help', 1)
-eq parse('-h TOPIC, --help TOPIC'), option('-h', '--help', 1)
-eq parse('-h TOPIC, --help=TOPIC'), option('-h', '--help', 1)
-
-eq parse('-h Description...'), option('-h', null)
-eq parse('-h --help Description...'), option('-h', '--help')
-eq parse('-h TOPIC Description...'), option('-h', null, 1)
-
-eq parse(' -h'), option('-h', null)
-
-eq parse('-h TOPIC Descripton... [default: 2]'),
- option('-h', null, 1, '2')
-eq parse('-h TOPIC Descripton... [default: topic-1]'),
- option('-h', null, 1, 'topic-1')
-eq parse('--help=TOPIC ... [default: 3.14]'),
- option(null, '--help', 1, '3.14')
-eq parse('-h, --help=DIR ... [default: ./]'),
- option('-h', '--help', 1, "./")
-
+ if argcount is 1
+ matched = description.match(/\[default: (.*)\]/)
+ value = if matched then matched[1] else false
+ new Option(short, long, argcount, value)
+
# same as TokenStream in python
-token_stream = (source) ->
- s: if source.constructor is String then source.split(/\s+/) else source
- move: -> if @s.length then @s.splice(0, 1)[0] else null
- current: -> if @s.length then @s[0] else null
-
-eq token_stream(['-o', 'arg']).s, ['-o', 'arg']
-eq token_stream('-o arg').s, ['-o', 'arg']
-eq token_stream('-o arg').move(), '-o'
-eq token_stream('-o arg').current(), '-o'
+class TokenStream extends Array
+ constructor: (source, @error) ->
+ stream =
+ if source.constructor is String
+ source.split(/\s+/)
+ else
+ source
+ @push.apply @, stream
+ move: -> @shift() or null
+ current: -> @[0] or null
+ toString: -> ([].slice.apply @).toString()
+ error: (message) ->
+ throw new @error(message)
@keleshev
keleshev Jun 9, 2012

TokenStream looks great!

parse_shorts = (tokens, options) ->
- raw = tokens.move()[1...]
+ raw = tokens.move()[1..]
parsed = []
while raw != ''
opt = (o for o in options when o.short and o.short[1] == raw[0])
if opt.length > 1
- print "-#{raw[0]} is specified ambiguously #{opt.length} times"
- exit
+ tokens.error "-#{raw[0]} is specified ambiguously #{opt.length} times"
if opt.length < 1
- print "-#{raw[0]} is not recognized"
- exit
+ tokens.error "-#{raw[0]} is not recognized"
opt = opt[0] #####copy? opt = copy(opt[0])
- raw = raw[1...]
+ raw = raw[1..]
if opt.argcount == 0
value = true
else
if raw == ''
if tokens.current() is null
- print "-#{opt.short[0]} requires argument"
- exit
+ tokens.error "-#{opt.short[0]} requires argument"
raw = tokens.move()
[value, raw] = [raw, '']
opt.value = value
parsed.push(opt)
return parsed
-eq(parse_shorts(token_stream('-a'), [option('-a')]),
- [option('-a', null, 0, true)])
-eq(parse_shorts(token_stream('-ab'), [option('-a'), option('-b')]),
- [option('-a', null, 0, true), option('-b', null, 0, true)])
-eq(parse_shorts(token_stream('-b'), [option('-a'), option('-b')]),
- [option('-b', null, 0, true)])
-eq(parse_shorts(token_stream('-aARG'), [option('-a', null, 1)]),
- [option('-a', null, 1, 'ARG')])
-eq(parse_shorts(token_stream('-a ARG'), [option('-a', null, 1)]),
- [option('-a', null, 1, 'ARG')])
-
parse_long = (tokens, options) ->
[_, raw,
@@ -122,59 +86,89 @@ parse_long = (tokens, options) ->
value = if value == '' then null else value
opt = (o for o in options when o.long and o.long[0...raw.length] == raw)
if opt.length < 1
- print "-#{raw} is not recognized"
- exit
+ tokens.error "-#{raw} is not recognized"
if opt.length > 1
- print "-#{raw} is not a unique prefix" # TODO report ambiguity
- exit
+ tokens.error "-#{raw} is not a unique prefix" # TODO report ambiguity
opt = opt[0] #copy? opt = copy(opt[0])
if opt.argcount == 1
if value is null
if tokens.current() is null
- print "#{opt.name} requires argument"
- exit
+ tokens.error "#{opt.name} requires argument"
value = tokens.move()
else if value is not null
- print "#{opt.name} must not have an argument"
- exit
+ tokens.error "#{opt.name} must not have an argument"
opt.value = value or true
return [opt]
-eq(parse_long(token_stream('--all'), [option(null, '--all')]),
- [option(null, '--all', 0, true)])
-eq(parse_long(token_stream('--all'), [option(null, '--all'),
- option(null, '--not')]),
- [option(null, '--all', 0, true)])
-eq(parse_long(token_stream('--all=ARG'), [option(null, '--all', 1)]),
- [option(null, '--all', 1, 'ARG')])
-eq(parse_long(token_stream('--all ARG'), [option(null, '--all', 1)]),
- [option(null, '--all', 1, 'ARG')])
-
parse_args = (source, options) ->
- tokens = token_stream(source)
- options = options.slice(0) # shallow copy, not sure if necessary
- opts = []
- args = []
+ tokens = new TokenStream(source)
+ #options = options.slice(0) # shallow copy, not sure if necessary
+ [opts, args] = [[], []]
while not (tokens.current() is null)
if tokens.current() == '--'
tokens.move()
- args = args.concat(tokens.s)
+ args = args.concat(tokens)
break
+ else if tokens.current()[0...2] == '--'
+ opts = opts.concat(parse_long(tokens, options))
+ else if tokens.current()[0] == '-' and tokens.current() != '-'
+ opts = opts.concat(parse_shorts(tokens, options))
else
- if tokens.current()[0...2] == '--'
- opts = opts.concat(parse_long(tokens, options))
- else
- if tokens.current()[0] == '-' and tokens.current() != '-'
- opts = opts.concat(parse_shorts(tokens, options))
- else
- args.push(tokens.move())
+ args.push(tokens.move())
return [opts, args]
-test_options = [option(null, '--all'), option('-b'), option('-W', null, 1)]
-eq(parse_args('--all -b ARG', test_options),
- [[option(null, '--all', 0, true), option('-b', null, 0, true)]
- ['ARG']])
-eq(parse_args('ARG -Wall', test_options),
- [[option('-W', null, 1, 'all')]
- ['ARG']])
+parse_doc_options = (doc) ->
+ (Option.parse('-' + s) for s in doc.split(/^ *-|\n *-/)[1..])
+
+printable_usage = (doc) ->
+ if usage = (/\s*usage:\s+/i).exec(doc)
+ usage = usage.replace(/^\s+/, '')
+ uses = doc.substr(usage.length).split(/\n\s*\n/)[0].split('\n')
+ ws = (new Array usage.length+1).join(' ')
+ return usage + (u.replace /^\s+|\s+$/, '' for u in uses).join(ws)
+ else
+ throw new UsageMessageError("the first word in the usage should be usage.")
+
+formal_usage = (printable_usage) ->
+ pu = printable_usage.split()[1..] # split and drop "usage:"
+ ((if s == pu[0] then '|' else s) for s in pu[1..]).join(' ')
+
+extras = (help, version, options, doc) ->
+ opts = {}
+ for opt in options
+ if opt.value
+ opts[opt.name()] = true
+ if help and (opts['--help'] or opts['-h'])
+ print(doc.strip())
+ exit()
+ if version and opts['--version']
+ print(version)
+ exit()
+
+docopt = (doc, argv=process.argv[1..], help=true, version=null) ->
+ DocoptExit.usage = docopt.usage = usage = printable_usage(doc)
+ pot_options = parse_doc_options(doc)
+ [options, args] = parse_args(argv, options=pot_options)
+
+ extras(help, version, options, doc)
+ formal_pattern = parse_pattern(formal_usage(usage), options=pot_options)
+# pot_arguments = [a for a in formal_pattern.flat
+# if type(a) in [Argument, Command]]
+# [matched, left, arguments] = formal_pattern.fix().match(argv)
+# if matched and left == []: # better message if left?
+# args = Dict((a.name, a.value) for a in
+# (pot_options + options + pot_arguments + arguments))
+# return args
+# throw new DocoptExit()
+
+__all__ =
+ docopt : docopt
+ Option : Option
+ TokenStream : TokenStream
+ parse_long : parse_long
+ parse_shorts : parse_shorts
+ parse_args : parse_args
+
+for fun of __all__
+ exports[fun] = __all__[fun]
View
101 test_docopt.coffee
@@ -0,0 +1,101 @@
+# it's a troublesome policy that node's got...
@keleshev
keleshev Jun 9, 2012

Could you tell me more about it?

@sonwell
sonwell Jun 9, 2012

This is referring to the inline with below. It's just a note that I never finished writing. I needed a way to make writing tests look natural without cluttering the code with @ everywhere.

To keep from polluting the global namespace, node.js wraps modules with an anonymous function with this bound to something else (seems to be an empty Object).

So ultimately I used with so that I could write stuff like TokenStream and it would look in the require'd module for TokenStream, although now that I've written this all out I think it would be better to write

(() ->
  # tests
).call(require './docopt')

edit: I guess what I should say is that coffeescript makes it very hard to define variables in the global namespace (since they are all block-scoped) and then node wraps the module in an anonymous function. The above won't work on it's own, and so the with is needed to make the tests look natural.

@keleshev
keleshev Jun 9, 2012

What about doing this in the top level:

{docopt, TokenStream, Option, etc} = require './docopt'

This way your tests will look as good as they are now + much less indented.

Python version has that:

from docopt import (docopt, DocoptExit, UsageMessageError,
                    Option, Argument, Command,
                    Required, Optional, Either, OneOrMore, AnyOptions,
                    parse_args, parse_pattern,
                    parse_doc_options, printable_usage, formal_usage
                   )

And I think it's not too bad. (Although the biggest reason I use explicit import is in order for pyflakes plugin in vim to point me to undefined/unimported names.)

@sonwell
sonwell Jun 9, 2012
@keleshev
keleshev Jun 9, 2012

Still, if you want to reduce indentation you can instead of:

test_opt_parse: ->
    eq Option.parse('-h'), new Option('-h', null)
    eq Option.parse('-h'), new Option('-h', null)

do

test "option parsing", ->
    eq Option.parse('-h'), new Option('-h', null)
    eq Option.parse('-h'), new Option('-h', null)

where test is something like:

test = (desc, fn) ->
    fn()

See coffe-script's testing dir: https://github.com/jashkenas/coffee-script/tree/master/test

+
+print = console.log
+eq = (a, b) ->
+ as = a.toString()
+ bs = b.toString()
+ if as == bs then return else throw new Error "#{as} != #{bs}"
+
+doc = require './docopt'
+
+((module) ->
+ `with (module) {//`
@keleshev
keleshev Jun 9, 2012

This looks funny. Inline JS's "with"?

+ tests =
+ test_opt_parse: ->
+ eq Option.parse('-h'), new Option('-h', null)
+ eq Option.parse('-h'), new Option('-h', null)
+ eq Option.parse('--help'), new Option(null, '--help')
+ eq Option.parse('-h --help'), new Option('-h', '--help')
+ eq Option.parse('-h, --help'), new Option('-h', '--help')
+
+ eq Option.parse('-h TOPIC'), new Option('-h', null, 1)
+ eq Option.parse('--help TOPIC'), new Option(null, '--help', 1)
+ eq Option.parse('-h TOPIC --help TOPIC'), new Option('-h', '--help', 1)
+ eq Option.parse('-h TOPIC, --help TOPIC'), new Option('-h', '--help', 1)
+ eq Option.parse('-h TOPIC, --help=TOPIC'), new Option('-h', '--help', 1)
+
+ eq Option.parse('-h Description...'), new Option('-h', null)
+ eq Option.parse('-h --help Description...'), new Option('-h', '--help')
+ eq Option.parse('-h TOPIC Description...'), new Option('-h', null, 1)
+
+ eq Option.parse(' -h'), new Option('-h', null)
+
+ eq Option.parse('-h TOPIC Descripton... [default: 2]'),
+ new Option('-h', null, 1, '2')
+ eq Option.parse('-h TOPIC Descripton... [default: topic-1]'),
+ new Option('-h', null, 1, 'topic-1')
+ eq Option.parse('--help=TOPIC ... [default: 3.14]'),
+ new Option(null, '--help', 1, '3.14')
+ eq Option.parse('-h, --help=DIR ... [default: ./]'),
+ new Option('-h', '--help', 1, "./")
+
+ test_token_stream: ->
+ eq new TokenStream(['-o', 'arg']), ['-o', 'arg']
+ eq new TokenStream('-o arg'), ['-o', 'arg']
+ eq new TokenStream('-o arg').move(), '-o'
+ eq new TokenStream('-o arg').current(), '-o'
+
+ test_parse_shorts: ->
+ eq(parse_shorts(new TokenStream('-a'), [new Option('-a')]),
+ [new Option('-a', null, 0, true)])
+ eq(parse_shorts(new TokenStream('-ab'), [new Option('-a'), new Option('-b')]),
+ [new Option('-a', null, 0, true), new Option('-b', null, 0, true)])
+ eq(parse_shorts(new TokenStream('-b'), [new Option('-a'), new Option('-b')]),
+ [new Option('-b', null, 0, true)])
+ eq(parse_shorts(new TokenStream('-aARG'), [new Option('-a', null, 1)]),
+ [new Option('-a', null, 1, 'ARG')])
+ eq(parse_shorts(new TokenStream('-a ARG'), [new Option('-a', null, 1)]),
+ [new Option('-a', null, 1, 'ARG')])
+
+ test_parse_long: ->
+ eq(parse_long(new TokenStream('--all'), [new Option(null, '--all')]),
+ [new Option(null, '--all', 0, true)])
+ eq(parse_long(new TokenStream('--all'), [new Option(null, '--all'),
+ new Option(null, '--not')]),
+ [new Option(null, '--all', 0, true)])
+ eq(parse_long(new TokenStream('--all=ARG'), [new Option(null, '--all', 1)]),
+ [new Option(null, '--all', 1, 'ARG')])
+ eq(parse_long(new TokenStream('--all ARG'), [new Option(null, '--all', 1)]),
+ [new Option(null, '--all', 1, 'ARG')])
+
+ test_parse_args: ->
+ test_options = [new Option(null, '--all'), new Option('-b'), new Option('-W', null, 1)]
+ eq(parse_args('--all -b ARG', test_options),
+ [[new Option(null, '--all', 0, true), new Option('-b', null, 0, true)]
+ ['ARG']])
+ eq(parse_args('ARG -Wall', test_options),
+ [[new Option('-W', null, 1, 'all')]
+ ['ARG']])
+
+ `}`
+
+ print '================================================================'
+
+ passes = 0
+ errors = []
+ for test of tests
+ try
+ tests[test]()
+ passes += 1
+ process.stdout.write '.'
+ catch e
+ errors.push([test, e])
+ process.stdout.write 'F'
+ print ''
+
+ for [test, e] in errors
+ print "In test #{test}, #{e.message}"
+
+ print "#{passes} successes, #{errors.length} failures"
+ print '================================================================'
+)(doc)

1 comment on commit 7d58155

@keleshev
docopt member

All in all looks great, and much more idiomatic than my code.

Please sign in to comment.