UNPKG

docopt

Version:

a command line option parser that will make you smile

589 lines (487 loc) 20.6 kB
print = -> console.log [].join.call arguments, ' ' enumerate = (array) -> i = 0 ([i++, item] for item in array) any = (array) -> return true in array zip = (args...) -> lengthArray = (arr.length for arr in args) length = Math.min(lengthArray...) for i in [0...length] arr[i] for arr in args String::partition = (separator) -> self = this if self.indexOf(separator) >= 0 parts = self.split(separator) return [parts[0], separator, parts.slice(1).join(separator)] else return [String(self), '', ''] String::startsWith = (searchString, position) -> position = position || 0 return this.lastIndexOf(searchString, position) == position String::endsWith = (searchString, position) -> subjectString = this.toString() if (position == undefined || position > subjectString.length) position = subjectString.length position -= searchString.length lastIndex = subjectString.indexOf(searchString, position) return lastIndex != -1 && lastIndex == position String::_split = -> this.trim().split(/\s+/).filter (i) -> i != '' String::isUpper = -> /^[A-Z]+$/g.exec(this) Number.isInteger = Number.isInteger || (value) -> return typeof value == "number" && isFinite(value) && Math.floor(value) == value class DocoptLanguageError extends Error constructor: (@message) -> super @message class DocoptExit extends Error constructor: (@message) -> super @message class Pattern extends Object fix: -> @fix_identities() @fix_repeating_arguments() @ fix_identities: (uniq=null) -> """Make pattern-tree tips point to same object if they are equal.""" if not @hasOwnProperty 'children' then return @ if uniq is null [uniq, flat] = [{}, @flat()] uniq[k] = k for k in flat for [i, c] in enumerate(@children) if not c.hasOwnProperty 'children' console.assert(uniq.hasOwnProperty(c)) @children[i] = uniq[c] else c.fix_identities uniq @ fix_repeating_arguments: -> """Fix elements that should accumulate/increment values.""" either = (child.children for child in transform(@).children) for mycase in either counts = {} for c in mycase counts[c] = (if counts[c] then counts[c] else 0) + 1 for e in (child for child in mycase when counts[child] > 1) if e.constructor is Argument or e.constructor is Option and e.argcount if e.value is null e.value = [] else if e.value.constructor isnt Array e.value = e.value._split() if e.constructor is Command or e.constructor is Option and e.argcount == 0 e.value = 0 @ transform = (pattern) -> """Expand pattern into an (almost) equivalent one, but with single Either. Example: ((-a | -b) (-c | -d)) => (-a -c | -a -d | -b -c | -b -d) Quirks: [-a] => (-a), (-a...) => (-a -a) """ result = [] groups = [[pattern]] while groups.length children = groups.shift() parents = [Required, Optional, OptionsShortcut, Either, OneOrMore] if (any((t in (children.map (c) -> c.constructor)) for t in parents)) child = (c for c in children when c.constructor in parents)[0] index = children.indexOf(child) if index >= 0 children.splice(index, 1) if child.constructor is Either for c in child.children groups.push([c].concat children) else if child.constructor is OneOrMore groups.push((child.children.concat(child.children)).concat children) else groups.push(child.children.concat children) else result.push(children) return new Either(new Required e for e in result) class LeafPattern extends Pattern """Leaf/terminal node of a pattern tree.""" constructor: (@name, @value=null) -> toString: -> "#{@.constructor.name}(#{@name}, #{@value})" flat: (types=[]) -> types = if types instanceof Array then types else [types] if not types.length or @.constructor in types return [@] else return [] match: (left, collected=null) -> collected = [] if collected is null [pos, match] = @.singleMatch left if match is null return [false, left, collected] left_ = left.slice(0, pos).concat(left.slice(pos + 1)) same_name = (a for a in collected when a.name == @.name) if Number.isInteger(@value) or @value instanceof Array if Number.isInteger(@value) increment = 1 else increment = if typeof match.value == 'string' then [match.value] else match.value if not same_name.length match.value = increment return [true, left_, collected.concat(match)] if Number.isInteger(@value) same_name[0].value += increment else same_name[0].value = [].concat(same_name[0].value, increment) return [true, left_, collected] return [true, left_, collected.concat(match)] class BranchPattern extends Pattern """Branch/inner node of a pattern tree.""" constructor: (children) -> @children = if children instanceof Array then children else [children] toString: -> "#{@.constructor.name}(#{(a for a in @children).join(', ')})" flat: (types=[]) -> types = if types instanceof Array then types else [types] if @.constructor in types then return [@] return (child.flat(types) for child in @children when child instanceof Pattern).reduce(((pv, cv) -> return [].concat pv, cv), []) class Argument extends LeafPattern singleMatch: (left) -> for [n, pattern] in enumerate(left) if pattern.constructor is Argument return [n, new Argument(@name, pattern.value)] return [null, null] @parse: (source) -> name = /(<\S*?>)/ig.exec(source)[1] value = /\[default:\s+(.*)\]/ig.exec(source) return new Argument(name, if value then value[1] else null) class Command extends Argument constructor: (@name, @value=false) -> singleMatch: (left) -> for [n, pattern] in enumerate(left) if pattern.constructor is Argument if pattern.value == @name return [n, new Command(@name, true)] else break return [null, null] class Option extends LeafPattern constructor: (@short=null, @long=null, @argcount=0, value=false) -> console.assert(@argcount in [0,1]) @value = if value is false and @argcount > 0 then null else value @name = @long or @short toString: -> "Option(#{@short}, #{@long}, #{@argcount}, #{@value})" @parse: (option_description) -> [short, long, argcount, value] = [null, null, 0, false] [options, _, description] = option_description.trim().partition(' ') options = options.replace /,|=/g, ' ' for s in options._split() # split on spaces if s.startsWith('--') long = s else if s.startsWith('-') short = s else argcount = 1 if argcount > 0 matched = /\[default:\s+(.*)\]/ig.exec(description) value = if matched then matched[1] else null new Option short, long, argcount, value singleMatch: (left) -> for [n, pattern] in enumerate(left) if @name == pattern.name return [n, pattern] return [null, null] class Required extends BranchPattern match: (left, collected=null) -> collected = [] if collected is null l = left #copy(left) c = collected #copy(collected) for p in @children [matched, l, c] = p.match(l, c) if not matched return [false, left, collected] [true, l, c] class Optional extends BranchPattern match: (left, collected=null) -> collected = [] if collected is null #left = copy(left) for p in @children [m, left, collected] = p.match(left, collected) [true, left, collected] class OptionsShortcut extends Optional """Marker/placeholder for [options] shortcut.""" class OneOrMore extends BranchPattern match: (left, collected=null) -> console.assert(@children.length == 1) collected = [] if collected is null l = left #copy(left) c = collected #copy(collected) l_ = [] matched = true times = 0 while matched # could it be that something didn't match but changed l or c? [matched, l, c] = @children[0].match(l, c) times += if matched then 1 else 0 if l_.join(', ') == l.join(', ') then break l_ = l #copy(l) if times >= 1 then return [true, l, c] [false, left, collected] class Either extends BranchPattern match: (left, collected=null) -> collected = [] if collected is null outcomes = [] for p in @children outcome = p.match(left, collected) if outcome[0] then outcomes.push(outcome) if outcomes.length > 0 outcomes.sort((a,b) -> if a[1].length > b[1].length 1 else if a[1].length < b[1].length -1 else 0) return outcomes[0] [false, left, collected] # same as Tokens in python class Tokens extends Array constructor: (source, @error=DocoptExit) -> stream = if source.constructor is String then source._split() else source @push.apply @, stream move: -> if @.length then [].shift.apply(@) else null current: -> if @.length then @[0] else null @from_pattern: (source) -> source = source.replace(/([\[\]\(\)\|]|\.\.\.)/g, ' $1 ') source = (s for s in source.split(/\s+|(\S*<.*?>)/) when s) return new Tokens source, DocoptLanguageError parse_section = (name, source) -> matches = source.match new RegExp('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', 'igm') if matches return (s.trim() for s in matches) return [] parse_shorts = (tokens, options) -> """shorts ::= '-' ( chars )* [ [ ' ' ] chars ] ;""" token = tokens.move() console.assert token.startsWith('-') and not token.startsWith('--') left = token.replace(/^-+/g, '') parsed = [] while left != '' [short, left] = ['-' + left[0], left[1..]] similar = (o for o in options when o.short == short) if similar.length > 1 throw new tokens.error("#{short} is specified ambiguously #{similar.length} times") else if similar.length < 1 o = new Option(short, null, 0) options.push(o) if tokens.error is DocoptExit o = new Option(short, null, 0, true) else # why copying is necessary here? o = new Option(short, similar[0].long, similar[0].argcount, similar[0].value) value = null if o.argcount != 0 if left == '' if tokens.current() in [null, '--'] throw new tokens.error("#{short} requires argument") value = tokens.move() else value = left left = '' if tokens.error is DocoptExit o.value = if value isnt null then value else true parsed.push(o) return parsed parse_long = (tokens, options) -> """long ::= '--' chars [ ( ' ' | '=' ) chars ] ;""" [long, eq, value] = tokens.move().partition('=') console.assert long.startsWith('--') value = null if (eq == value and value == '') similar = (o for o in options when o.long == long) if tokens.error is DocoptExit and similar.length == 0 # if no exact match similar = (o for o in options when o.long and o.long.startsWith(long)) if similar.length > 1 # might be simply specified ambiguously 2+ times? longs = (o.long for o in similar).join(', ') throw new tokens.error("#{long} is not a unique prefix: #{longs}?") else if similar.length < 1 argcount = if (eq == '=') then 1 else 0 o = new Option(null, long, argcount) options.push(o) if tokens.error is DocoptExit o = new Option(null, long, argcount, if argcount > 0 then value else true) else o = new Option(similar[0].short, similar[0].long, similar[0].argcount, similar[0].value) if o.argcount == 0 if value isnt null throw new tokens.error("#{o.long} must not have an argument") else if value is null if tokens.current() in [null, '--'] throw new tokens.error("#{o.long} requires argument") value = tokens.move() if tokens.error is DocoptExit o.value = if value isnt null then value else true return [o] parse_pattern = (source, options) -> tokens = Tokens.from_pattern source result = parse_expr tokens, options if tokens.current() isnt null throw new tokens.error 'unexpected ending: ' + (tokens.join ' ') new Required result parse_expr = (tokens, options) -> """expr ::= seq ( '|' seq )* ;""" seq = parse_seq tokens, options if tokens.current() != '|' return seq result = if seq.length > 1 then [new Required seq] else seq while tokens.current() is '|' tokens.move() seq = parse_seq tokens, options result = result.concat(if seq.length > 1 then [new Required seq] else seq) return if result.length > 1 then [new Either result] else result parse_seq = (tokens, options) -> """seq ::= ( atom [ '...' ] )* ;""" result = [] while tokens.current() not in [null, ']', ')', '|'] atom = parse_atom tokens, options if tokens.current() is '...' atom = [new OneOrMore atom] tokens.move() result = result.concat atom return result parse_atom = (tokens, options) -> """atom ::= '(' expr ')' | '[' expr ']' | 'options' | long | shorts | argument | command ; """ token = tokens.current() result = [] if token in '([' tokens.move() [matching, patternType] = {'(': [')', Required], '[': [']', Optional]}[token] result = new patternType parse_expr(tokens, options) if tokens.move() != matching throw new tokens.error "Unmatched '"+token+"'" return [result] else if token is 'options' tokens.move() return [new OptionsShortcut] else if token.startsWith('--') and token != '--' return parse_long tokens, options else if token.startsWith('-') and token not in ['-', '--'] return parse_shorts(tokens, options) else if token.startsWith('<') and token.endsWith('>') or token.isUpper() return [new Argument(tokens.move())] else [new Command tokens.move()] parse_argv = (tokens, options, options_first=false) -> """Parse command-line argument vector. If options_first: argv ::= [ long | shorts ]* [ argument ]* [ '--' [ argument ]* ] ; else: argv ::= [ long | shorts | argument ]* [ '--' [ argument ]* ] ; """ parsed = [] while tokens.current() isnt null if tokens.current() == '--' return parsed.concat(new Argument(null, v) for v in tokens) else if tokens.current().startsWith('--') parsed = parsed.concat(parse_long(tokens, options)) else if tokens.current().startsWith('-') and tokens.current() != '-' parsed = parsed.concat(parse_shorts(tokens, options)) else if options_first return parsed.concat(new Argument(null, v) for v in tokens) else parsed.push(new Argument(null, tokens.move())) return parsed parse_defaults = (doc) -> defaults = [] for s in parse_section('options:', doc) # FIXME corner case "bla: options: --foo" [_, _, s] = s.partition(':') # get rid of "options:" split = ('\n' + s).split(new RegExp('\\n[ \\t]*(-\\S+?)')).slice(1) odd = (v for v in split by 2) even = (v for v in split[1..] by 2) split = (s1 + s2 for [s1, s2] in zip(odd, even)) options = (Option.parse(s) for s in split when s.startsWith('-')) defaults.push.apply(defaults, options) return defaults formal_usage = (section) -> [_, _, section] = section.partition ':' # drop "usage:" pu = section._split() return '( ' + ((if s == pu[0] then ') | (' else s) for s in pu[1..]).join(' ') + ' )' extras = (help, version, options, doc) -> if help and any((o.name in ['--help', '-h']) and o.value for o in options) return doc.replace /^\s*|\s*$/, '' if version and any((o.name == '--version') and o.value for o in options) return version return "" class Dict extends Object constructor: (pairs) -> @[key] = value for [key, value] in pairs toObject: () -> dict = {} dict[name] = @[name] for name in Object.keys(@).sort() return dict docopt = (doc, kwargs={}) -> allowedargs = ['argv', 'name', 'help', 'version', 'options_first', 'exit'] throw new Error "unrecognized argument to docopt: " for arg of kwargs \ when arg not in allowedargs argv = if kwargs.argv is undefined \ then process.argv[2..] else kwargs.argv name = if kwargs.name is undefined \ then null else kwargs.name help = if kwargs.help is undefined \ then true else kwargs.help version = if kwargs.version is undefined \ then null else kwargs.version options_first = if kwargs.options_first is undefined \ then false else kwargs.options_first exit = if kwargs.exit is undefined \ then true else kwargs.exit try usage_sections = parse_section 'usage:', doc if usage_sections.length == 0 throw new DocoptLanguageError '"usage:" (case-insensitive) not found.' if usage_sections.length > 1 throw new DocoptLanguageError 'More than one "usage:" (case-insensitive).' DocoptExit.usage = usage_sections[0] options = parse_defaults doc pattern = parse_pattern formal_usage(DocoptExit.usage), options argv = parse_argv new Tokens(argv), options, options_first pattern_options = pattern.flat(Option) for options_shortcut in pattern.flat(OptionsShortcut) doc_options = parse_defaults(doc) pattern_options_strings = (i.toString() for i in pattern_options) options_shortcut.children = doc_options.filter((item) -> return item.toString() not in pattern_options_strings) output = extras help, version, argv, doc if output if exit print output process.exit() else throw new Error output [matched, left, collected] = pattern.fix().match argv if matched and left.length is 0 # better message if left? return new Dict([a.name, a.value] for a in ([].concat pattern.flat(), collected)).toObject() throw new DocoptExit DocoptExit.usage catch e if (!exit) throw e else print e.message if e.message process.exit(1) module.exports = docopt : docopt DocoptLanguageError : DocoptLanguageError DocoptExit : DocoptExit Option : Option Argument : Argument Command : Command Required : Required OptionsShortcut : OptionsShortcut Either : Either Optional : Optional Pattern : Pattern OneOrMore : OneOrMore Tokens : Tokens Dict : Dict transform : transform formal_usage : formal_usage parse_section : parse_section parse_defaults: parse_defaults parse_pattern: parse_pattern parse_long : parse_long parse_shorts : parse_shorts parse_argv : parse_argv