diff --git a/Makefile b/Makefile index 33f9492..081458f 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,6 @@ test: coffee nodeunit test/*.js clean: - rm $(JSFILES) + rm -f $(JSFILES) .PHONY : all test coffee diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..b5197c4 --- /dev/null +++ b/README.markdown @@ -0,0 +1,239 @@ +Getopt +====== + +For the impatient +----------------- +If you're in a hurry and you just want to parse your options already: + + getopt = require('getopt'); + options = getopt.simple({ + verbose: { + type: getopt.flag, + short: 'v', + }, + input: { + type: getopt.list, + short: 'i', + }, + output: { + type: getopt.scalar, + description: 'output file (- for stdout)', + default: '-', + short: 'o', + required: true, + }, + }); + +options.verbose is true or false, input is an array (possibly empty), and +output is a string (or undefined). You also get a --help option for free that +prints out: + + node myscript.pl [OPTIONS] + + The following options are accepted: + + -h + --help Prints this message and exits. + + -v + --verbose + + + -i VAL + --input=VAL + + -o VAL + --output=VAL output file (- for stdout). Required. Default: - + +getopt.simple() doesn't do what I want! +======================================= +Yeah, sorry about that. getopt.simple() does what I usually want with a +minimum of fuss and ceremony, but have a look at the lower level APIs. Very +probably, you can coerce them into doing what you want, though you might have +to write try/catch blocks (the horror!) and call process.exit() yourself, and +so on. The whole API is designed to allow you to use just the parts of it that +are useful for you. So, read the rest of the documentation :) + +Why? +---- + +As if Node didn't have enough option parsers. As of this writing, most of them +are almost good enough for me. None of them quite measures up to the power of +perl's Getopt::Long though. Getopt::Long's interface sucks, but its parser is +very flexible. This module aims to have an interface that doesn't suck and +still be flexible, but you'll be the judge. + +Specification +------------- + +The primary way you give information to getopt is through a specification +object. The keys are the names of targets (keys in the result object), and the +values are objects with the following keys: + +### type + +This tells getopt what kind of thing you're trying to parse. It absolutely +must be one of the following - you cannot pass a string, and there is no +default. + +#### getopt.flag (or getopt.bool) + +Just an "on" switch. It'll be true if it was passed, and false if it wasn't. +All the following forms are valid: + + -vax + -v -a -x + --verbose --anthropomorphic --xenophobic + +#### getopt.scalar (or getopt.string) + +Expects one (and only one) value. If present at all, its value will be some +kind of string. Passing more than one argument for options of this type will +make getopt throw an error. The following forms are all valid: + + -a42 + -a 42 + --answer 42 + --answer=42 + +#### getopt.array (or getopt.list) + +Expects zero or more values. You'll get an array in the result object of all +the strings passed for this argument. + + node script.js --foo=one --foo=two --foo=three +*** + { foo: ['one', 'two', 'three'] } + +#### getopt.object (or getopt.hash) + +This one is a little odd: like list, you can pass it multiple times, but the +result will be an object (or hash) instead of an array, and the value will be +split into key/value on the = sign. An example will probably explain better: + + define: { + type: getopt.object, + short: '-D' + } +*** + node --script.js -Dfoo=bar -Dbar=baz +*** + { define: { foo: "bar", bar: "baz" } } + +### short + +Either a string or an array of strings. All must be one character long (leave +off the -). This creates short aliases for your argument. The target will be +the same, though, and aliases can be mixed. In addition, for flag type shorts, +they can be chained together. No short aliases are created by default. + +### long + +A list of long names for your option, either a string or an array of strings +(leave off the --). By default, this is just the name of your target. If you +do specify some longs, you must also include the name of your target if you +wish it to be a long alias. + +### required + +A boolean. This causes getopt.parse to throw an exception if the argument +wasn't given, and is only valid for scalars and lists. In the list case, at +least one value must be given. + +### default + +A default value for your option if none is parsed from the command line. +Arrays and objects default to empty arrays and objects unless you say +otherwise. + +### description + +Purely optional, this should be a string explaining what your option does. The +help generator makes use of this -- see getopt.help() for details. + +Positional Arguments +-------------------- +Anything that doesn't look like an option will get aggregated into the result +object's .argv property, in the order it was found in the actual argv. '-' is +treated as positional, and '--' will cause getopt to stop processing and +report the rest of the args as positional. + +Unrecognized Arguments +---------------------- +Any options given to the program that aren't specified in the options spec +will cause an error to be thrown. + +API +=== + +### getopt.parse(spec, argv) + +Processes argv without modifying it and returns a result object, which will +have an argv property and other properties depending on the option spec. Any +errors in parsing will throw an exception. If you don't specify argv, +process.argv (minus the first two elements) will be used. + +### getopt.tryParse(spec, argv) + +Wraps getopt.parse in a try/catch block. If exceptions are encountered, they +are printed to stderr and the process will exit with a non-zero code. + +### getopt.zero() + +Returns the program name (e.g. "node myscript.js"). Useful when generating a +usage message. + +### getopt.usage() + +Uses getopt.zero() to generate a usage message of the form: +"Usage: node myscript.js [OPTIONS]". This is the default message used by +getopt.simple(). + +### getopt.help(spec) + +Returns a formatted string describing the options specified. Used internally +by getopt.simple(), but you are encouraged to use it outside that context. + +The description field of the spec is examined. If you didn't include a period +at the end of the description, one will be added. Other bits of explanatory +text (like "Required", "Default: " or "Can be specified multiple times") will +be added to the end of the description to keep you from having to duplicate +spec information in the description. + +### getopt.simple(spec, banner, argv) + +Behaves similarly to getopt.tryParse(), except that it adds a "help" option +and then checks for it. If --help is passed, spec will be passed to +getopt.help() to generate some the help. The given banner will be printed, +followed by this help. Banner and argv are both optional: banner defaults to +getopt.usage(), and argv() defaults to process.argv as in getopt.parse(). + +Errors +====== +There is a whole heirarchy of error classes, mostly for testing purposes. If +you don't catch them, node will print a stacktrace for them like the common +errors. If you do, you can use their "message" member to print out something +useful to the user. + +Any time you create a Parser or Help object (internal classes used by the api +methods above), an exception will be thrown if there is something inconsistent +about your option specification. This helps you catch errors sooner. These +exceptions are instances of getopt.SpecError, but you probably shouldn't try +to catch them. getopt.tryParse et al will rethrow them. + +Calling getopt.parse() an throw exceptions if the user has given bad options +on the command line. These are generally the ones you want to try to catch and +print. They are all instances of getopt.ParseError. + +I think getopt should behave differently. +========================================= + +I value your opinion, I really do. I also have a job, and it isn't maintaining +getopt. Please, please either include a patch in your correspondance or send +me a pull request on github. Otherwise, your issue may or may not be +addressed, but I'm gonna go out on a limb and say it probably won't be. Thanks +in advance for your contributions :) + +If you insist on not fixing my code for me, though, you can report an issue +through github. If it's a bug that effects me, it will very likely get fixed. +If not, perhaps you should reconsider fixing my code for me :) diff --git a/README.md b/README.md deleted file mode 100644 index f1cefe9..0000000 --- a/README.md +++ /dev/null @@ -1,122 +0,0 @@ -special flags -============= -A single dash '-' is treated as positional. -Double dash '--' is removed from the array and stops processing, so that -everything that comes after it is treated as positional. - -the specification object -======================== - -keys are target names (what you check for in the result object) -values are the specification for that target. Keys for the specification are: - -short: - array, each element is a one-character string. If you specify nothing, - there will be no short alias. - -long: - array, each element is a string. If you specify nothing, the name of the - target will be used. - -type: - needs to be one of getopt.boolean, getopt.string, getopt.array, or - getopt.object. See the "types" section. - -types -===== - -flag: - No value is allowed. Short flags can appear in groups such that - -vax - is equivalent to - -v -a -x - - Long flags look like: - --verbose --add --xerox - - If the user tries to give a value for a flag with an equals, e.g. - -v=42 - --verbose=42 - then an error will be thrown. If they try to give one with a space, - though, , e.g. - -v 42 - --verbose 42 - - then -v will be set and 42 will be treated as a positional argument. - - A flag can be specified multiple times without error. - -scalar: - A single value is allowed. Not giving a value throws an error. Short - scalars look like: - -v 42 - -v=42 - - Long scalars look like: - --verbosity 42 - --verbosity=42 - - Scalars take an additional option, "required" (defaults to false). If this - is set, an error will be thrown if no value is supplied or the supplied - value is the empty string. Specifying this will cause "(required)" to be - appended to your description. - - Scalars can also have a "default". This will be the value if none is - specified, although an empty value can still be specified like - --verbosity= - - Specifying this will cause "(default: value)" to be appended to the - description. - -array: - The option can be specified zero, one, or many times, and the value will - be aggregated. This looks like: - - --foo one --foo two --foo three - -f=one -f=two -f=three - etc. - - Arrays can use "required" and "default" just like scalars, except that - default should be an array. If any values are supplied, the default is not - used. - -object: - The values supplied must be in the form "name=value". This looks like: - -D foo=bar --define bar=baz -D=baz=qux --define=qux=quux - - Supplying the same key twice will cause an error to be thrown. - - Objects can use "default" just like scalars, except - that default should be an object. If any key/value pairs are supplied, the - default is ignored. - - Objects cannot be "required". - -positional arguments -==================== - -Anything that doesn't look like an option will be aggregated into the -"positional" array, and can appear anywhere in the argument list. - -unrecognized arguments -====================== - -Any options given to the program that aren't specified in the options spec -will cause an error to be thrown. - -errors -====== -Errors are thrown when you call "parse", and are string exceptions. -Typical usage would be something like: - -parser = new GetOpt(spec) - -try { - parser.parse(process.argv) -} -catch (e) { - console.log(e) - console.log(parser.usage()) -} - -But you can of course do something different. diff --git a/examples/simple.coffee b/examples/simple.coffee new file mode 100644 index 0000000..cf29b1e --- /dev/null +++ b/examples/simple.coffee @@ -0,0 +1,14 @@ +getopt = require 'getopt' +sys = require 'sys' + +o = getopt.simple + munge: + type: getopt.flag + description: 'Whether or not to munge' + ickiness: + type: getopt.scalar + default: 1 + description: 'How icky to make the munging' + +if o.munge + sys.puts "Munge..." for [1..o.ickiness] diff --git a/lib/Help.coffee b/lib/Help.coffee index b7191c5..bed7072 100644 --- a/lib/Help.coffee +++ b/lib/Help.coffee @@ -32,33 +32,40 @@ exports.Help = class Help left.push leftCol(sub, s, true) for s in sub.long.slice(0).sort() line.left = left - line.fl = left[0] - line.ll = left[left.length-1] - line.right = sub.description.trim() - line.right += '.' unless line.right.match(/\.$/) - line.right += ' Required.' if sub.required + line.first = left[0] + line.last = left[left.length-1] + + right = [] + if desc = sub.description.trim().replace(/\.$/, '') + right.push desc + + right.push 'Required' if sub.required + if sub.type is type.array or sub.type is type.object + right.push 'Can be specified multiple times' if sub.default - line.right += ' Default: ' + JSON.stringify(sub.default) + right.push 'Default: ' + JSON.stringify(sub.default) + + line.right = right.join('. ') + line.right += '.' if right.length lines.push line # Right-justify the left sides - lengths = (l.ll.length for l in lines) + lengths = (l.last.length for l in lines) max = Math.max.apply(Math, lengths) for line in lines for l, i in line.left pad = max - l.length l = ' ' + l for [1..pad] if pad - line.left[i] = l + ' ' - - # Left-justify the right sides two spaces away + line.left[i] = l + line.left[line.left.length-1] += ' ' + # Left-justify the right sides two spaces away, wrapped to tw remaining = @tw - max - 2 sep = "\n" sep += ' ' for [1..max+2] - for l, i in lines words = (w.trim() for w in l.right.split /\s/) words = (w for w in words if w.length > 0) @@ -73,7 +80,10 @@ exports.Help = class Help cur = w lines[i].right = all + cur - lines.sort (a, b) -> (a.rank - b.rank) or a.fl.localeCompare(b.fl) - out = (l.left.join("\n") + l.right for l in lines).join "\n\n" - console.log out - out + # Flags first, then scalars, etc -- sorted by beginning of line in + # lex order. This is as much so that we can have a canonical order to + # test as anything. + lines.sort (a, b) -> + (a.rank - b.rank) or a.first.localeCompare(b.first) + + (l.left.join("\n") + l.right for l in lines).join "\n\n" diff --git a/lib/getopt.coffee b/lib/getopt.coffee index ece9c74..206e5e7 100644 --- a/lib/getopt.coffee +++ b/lib/getopt.coffee @@ -1,3 +1,6 @@ +file = require 'file' +path = require 'path' + reexport = (module) -> m = require module exports[key] = val for own key, val of m @@ -8,3 +11,57 @@ reexport m for m in [ './Parser' './Help' ] + +getargs = (argv) -> argv or process.argv.slice 2 + +exports.parse = (spec, argv) -> + new exports.Parser(spec).parse getargs argv + +exports.zero = () -> + abs = path.normalize process.argv[1] + rel = file.path.relativePath process.cwd(), abs + script = if rel.length < abs.length then rel else abs + "#{ process.argv[0] } #{ script }" + +exports.usage = () -> "\nUsage: #{ exports.zero() } [OPTIONS]\n" +exports.help = (spec) -> new exports.Help(spec).toString() + +exports.tryParse = (spec, argv) -> + p = new exports.Parser(spec) + try + o = p.parse getargs argv + catch e + throw e unless e instanceof exports.ParseError + process.stderr.write e.message + "\n" + process.exit 1 + + return o + +exports.simple = exports.tryParseWithHelp = (spec, banner, argv) -> + spec.help or= + type: exports.flag + short: 'h' + description: 'Print this message and exit' + + p = new exports.Parser spec + try + argv = getargs argv + o = p.parse getargs argv + if o.help + h = exports.help spec + banner or= exports.usage() + process.stdout.write [ + banner + "The following options are recognized:\n" + h, + ].join("\n") + "\n\n" + process.exit 0 + catch e + throw e unless e instanceof exports.ParseError + h = spec.help + flag = if h.long then "--#{ h.long }" else "-#{ h.short }" + process.stderr.write e.message + + ". Pass #{ flag } for usage information.\n" + process.exit 1 + + return o diff --git a/lib/types.coffee b/lib/types.coffee index 904cc5c..7863809 100644 --- a/lib/types.coffee +++ b/lib/types.coffee @@ -1,6 +1,7 @@ exports[t] = t.toUpperCase() for t in ['flag', 'scalar', 'array', 'object'] exports[key] = exports[val] for own key, val of { + string: 'scalar' list: 'array' hash: 'object' bool: 'flag' diff --git a/package.json b/package.json index 3eafe26..96e42da 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,10 @@ "test" : "./test" }, "maintainers" : [ - { name: "Paul Drvier", email: "frodwith@gmail.com" } + { "name": "Paul Driver", "email": "frodwith@gmail.com" } ], "contributors" : [ - { name: "Paul Drvier", email: "frodwith@gmail.com" } + { "name": "Paul Drvier", "email": "frodwith@gmail.com" } ], "licences" : [ { diff --git a/test/help.coffee b/test/help.coffee index fe3cf30..716da01 100644 --- a/test/help.coffee +++ b/test/help.coffee @@ -1,5 +1,25 @@ getopt = require '../lib/getopt' +expected = ''' + -v + --verbose Print debugging messages. + + --password=VAL Secret string to use when connecting to server. This + description is going to be ridiculously long so that we can + test the line breaking a bit. Required. + + -o VAL + --output=VAL Filename (- for stdout) to write output to. Default: "-". + + -i VAL + --input=VAL Filename(s) (- for stdin) to read input from. Can be + specified multiple times. Default: ["-"]. + + -D KEY=VAL + --define KEY=VAL Symbols to define during processing. Can be specified + multiple times. Default: {}. + ''' + exports.basic = (t) -> t.expect 1 spec = @@ -19,7 +39,6 @@ exports.basic = (t) -> default: ['-'] password: type: getopt.scalar - short: 'p' description: 'Secret string to use when connecting to server. This description is going to be ridiculously long so that we can test the line breaking a bit' required: true symbols: @@ -28,23 +47,6 @@ exports.basic = (t) -> long: 'define' description: 'Symbols to define during processing' u = new getopt.Help spec - t.equals u.toString(), ''' --v ---verbose Print debugging messages. - --o VAL ---output=VAL Filename (- for stdout) to write output to. default: - - ---password=VAL Secret string to use when connecting to server. This - description is going to be ridiculously long so that we can - test the line breaking a bit. Required. - --i VAL ---input=VAL Filename(s) (- for stdin) to read input from. Can be - specified multiple times. - --D KEY=VAL ---define KEY=VAL Symbols to define during processing. Can be specified - multiple times. -''' + console.log(u.toString()) + t.equals u.toString(), expected t.done() diff --git a/test/parse.coffee b/test/parse.coffee index 28bad1f..1fbb694 100644 --- a/test/parse.coffee +++ b/test/parse.coffee @@ -178,10 +178,11 @@ exports.long = (t) -> exports['aliased types'] = (t) -> - t.expect 3 + t.expect 4 t.equal(getopt.list, getopt.array) t.equal(getopt.hash, getopt.object) t.equal(getopt.bool, getopt.flag) + t.equal(getopt.string, getopt.scalar) t.done() exports.complicated = (t) ->