diff --git a/README.md b/README.md index 40549d1..71c2b22 100644 --- a/README.md +++ b/README.md @@ -488,3 +488,89 @@ Will return: ``` erlang {ok,{[{path,"/john's files"}],["dummy"]}} ``` + +Checking required options +========================= + +There are four functions available for checking required options: +``` erlang +getopt:check/2, getopt:check/3, +getopt:parse_and_check/2, getopt:parse_and_check/3 +``` +If there are any options in the specification whose option types are not +tuples and not ``undefined``, these functions will return ``{error, Reason}`` +so that the caller can output the program usage by calling ``getopt:usage/2`` + +These functions accept an optional ``CheckOpt`` argument that controls their +behavior: + +``` erlang +CheckOpts = [help | {skip, [Name::atom()]}] + + help - instructs getopt:check/3 to return 'help' + when "-h" command line option is given and + option specification contains definition of 'help'. + This is needed to short-circuit the checker of + required options when help is requested. + + {skip, [Name]} - tells the checker to skip checking listed options +``` +Example: + +``` erlang +Spec = + [{help,$h,"help",undefined,"Help string"} + ,{port,$p,"port",integer, "Server port"}], + +{error,{missing_required_option,port}} = + getopt:parse_and_check(Spec, "-h"). + +help = getopt:parse_and_check(Spec, "-h", [help]). +``` + +Converting parsed list of arguments to a record +=============================================== + +Occasionally it is more convenient to represent the parsed arguments in the +form of a record rather than a list. Use ``getopt:to_record/3,4`` for this +purpose. + +```erlang +-record(args, {host, port, verbose}). + +OptionSpecs = [{host, $h, undefined, string, []}, + {port, $p, undefined, integer, []}, + {verbose, $v, undefined, integer, []}], + +{ok, ParsedArgs, _Other} = getopt:parse(OptionSpecs, "-v -h localhost -p 8000"), + +% Now ParsedArgs is a list: +% [{verbose, 1}, {host, "localhost"}, {port, 8000}], []} + +Args = getopt:to_record(ParsedArgs, record_info(fields, args), #args{}), + +% Test that Args record was filled with all parsed arguments from the OptionSpecs: + +Args = #args{host = "localhost", port = 8000, verbose = 1}. +``` + +It is also possible to pass a custom validation function to ``getopt:to_record/4``. +That function allows to translate the values assigned to the record's fields, as +well as to ignore some options. The arguments to the function are: +``(OptionName::atom(), OldFieldValue, ArgumentValue) -> {ok, FieldValue} | ignore``. + +```erlang +-record(args2, {host, port}). + +Fun = fun(verbose, _Old, _) -> ignore; + (port, _Old, N) when N < 1024 -> throw({invalid_port, N}); + (port, _Old, N) -> {ok, N+1}; + (_Opt, _Old, Value) -> {ok, Value} + end, + +Args2 = getopt:to_record(ParsedArgs, record_info(fields, args2), #args2{}, Fun), + +% Test that Args2 record was filled with all parsed arguments from the OptionSpecs: + +Args2 = #args2{host = "localhost", port = 8001}. +``` diff --git a/src/getopt.erl b/src/getopt.erl index 462fb0c..d28ea40 100644 --- a/src/getopt.erl +++ b/src/getopt.erl @@ -11,7 +11,10 @@ -module(getopt). -author('juanjo@comellas.org'). --export([parse/2, check/2, parse_and_check/2, format_error/2, +-export([parse/2, check/2, check/3, + parse_and_check/2, parse_and_check/3, + to_record/3, to_record/4, + format_error/2, usage/2, usage/3, usage/4, tokenize/1]). -export([usage_cmd_line/2]). @@ -39,6 +42,10 @@ -type simple_option() :: atom(). -type compound_option() :: {atom(), arg_value()}. -type option() :: simple_option() | compound_option(). +%% Option types for configuration option checking. +-type check_option() :: help | {skip, [Name::atom()]}. +-type check_options() :: [ check_option() ]. + %% Command line option specification. -type option_spec() :: { Name :: atom(), @@ -66,14 +73,28 @@ -spec parse_and_check([option_spec()], string() | [string()]) -> {ok, {[option()], [string()]}} | {error, {Reason :: atom(), Data :: term()}}. parse_and_check(OptSpecList, CmdLine) when is_list(OptSpecList), is_list(CmdLine) -> + parse_and_check(OptSpecList, CmdLine, []). + +%% @doc Parse the command line options and arguments returning a list of tuples +%% and/or atoms using the Erlang convention for sending options to a +%% function. Additionally perform check if all required options (the ones +%% without default values) are present. The function is a combination of +%% two calls: parse/2 and check/2. The `CheckOpts' argument allows to specify +%% a list of options to exclude from the required check (via `{skip, [Name::atom()]}') +%% and to specify `help', which will ignore required options check if +%% `CmdLine' contains help switch and `help' option is present in the `OptSpecList'. +-spec parse_and_check([option_spec()], string() | [string()], check_options()) -> + {ok, {[option()], [string()]}} | help | {error, {Reason :: atom(), Data :: term()}}. +parse_and_check(OptSpecList, CmdLine, CheckOpts) + when is_list(OptSpecList), is_list(CmdLine), is_list(CheckOpts) -> case parse(OptSpecList, CmdLine) of {ok, {Opts, _}} = Result -> - case check(OptSpecList, Opts) of + case check(OptSpecList, Opts, CheckOpts) of ok -> Result; - Error -> Error + Other -> Other end; - Error -> - Error + Other -> + Other end. %% @doc Check the parsed command line arguments returning ok if all required @@ -82,9 +103,32 @@ parse_and_check(OptSpecList, CmdLine) when is_list(OptSpecList), is_list(CmdLine -spec check([option_spec()], [option()]) -> ok | {error, {Reason :: atom(), Option :: atom()}}. check(OptSpecList, ParsedOpts) when is_list(OptSpecList), is_list(ParsedOpts) -> + check(OptSpecList, ParsedOpts, []). + +%% @doc Check the parsed command line arguments returning ok if all required +%% options (i.e. that don't have defaults) are present, and returning +%% error otherwise. The `CheckOpts' argument allows to specify +%% a list of options to exclude from the required check (via `{skip, [Name::atom()]}') +%% and to specify `help', which will ignore required options check if +%% `CmdLine' contains help switch and `help' option is present in the `OptSpecList'. + +-spec check([option_spec()], [option()], check_options()) -> + ok | help | {error, {Reason :: atom(), Option :: atom()}}. +check(OptSpecList, ParsedOpts, CheckOpts) + when is_list(OptSpecList), is_list(ParsedOpts), is_list(CheckOpts) -> try + CheckHelp = proplists:get_value(help, CheckOpts, false), + HasHelp = lists:keymember(help, 1, OptSpecList) + andalso proplists:get_value(help, ParsedOpts, false), + SkipOpts = proplists:get_value(skip, CheckOpts, []), + + {CheckHelp, HasHelp} =:= {true, true} + andalso throw(help), + + % Ignore checking of options present in the {skip, Skip} list RequiredOpts = [Name || {Name, _, _, Arg, _} <- OptSpecList, - not is_tuple(Arg) andalso Arg =/= undefined], + not is_tuple(Arg), Arg =/= undefined, + not lists:member(Name, SkipOpts)], lists:foreach(fun (Option) -> case proplists:is_defined(Option, ParsedOpts) of true -> @@ -94,6 +138,8 @@ check(OptSpecList, ParsedOpts) when is_list(OptSpecList), is_list(ParsedOpts) -> end end, RequiredOpts) catch + throw:help -> + help; _:Error -> Error end. @@ -142,6 +188,45 @@ parse(OptSpecList, OptAcc, ArgAcc, _ArgPos, []) -> %% not present but had default arguments in the specification. {ok, {lists:reverse(append_default_options(OptSpecList, OptAcc)), lists:reverse(ArgAcc)}}. +%% @doc Convert options from a list into a record. +%% The `FieldNames' list is the result of a call to `record_info(fields, Record)', and +%% `Record' is the initial value of the record to be populated with option values. +-spec to_record([option()], [atom()], tuple()) -> tuple(). +to_record(Options, FieldNames, Record) -> + to_record(Options, FieldNames, Record, fun(_,_,V) -> {ok, V} end). + +%% @doc Convert options from a list into a record. +%% This function is equivalent to `opts_to_record/3' except for taking another +%% `Validate' argument, which is a function +%% `(Field, OldFieldValue, OptionValue) -> {ok, NewFieldValue} | ignore' +%% used for validating the `Field' before it's assigned to the corresponding field +%% in the `Record'. Options with `undefined' values are skipped. +-spec to_record([option()], [atom()], tuple(), + fun((atom(), term(), term()) -> {ok, term()} | ignore)) -> tuple(). +to_record(Options, RecordFieldNames, Record, Validate) when is_function(Validate, 3) -> + lists:foldl(fun + ({_Opt,undefined}, Rec) -> + Rec; + (Opt, Rec) when is_atom(Opt) -> + set_val(Opt, true, Rec, RecordFieldNames, Validate); + ({Opt, Value}, Rec) -> + set_val(Opt, Value, Rec, RecordFieldNames, Validate) + end, Record, Options). + +set_val(Opt, Value, Rec, RecordFieldNames, Validate) -> + I = pos(RecordFieldNames, Opt, 2), + case Validate(Opt, old_val(I, Rec), Value) of + {ok, V} when I > 1 -> setelement(I, Rec, V); + {ok, _} -> throw({field_not_found, Opt, RecordFieldNames}); + ignore -> Rec + end. + +old_val(0,_Rec) -> undefined; +old_val(N, Rec) -> element(N, Rec). + +pos([], _, _) -> 0; +pos([H|_], H, N) -> N; +pos([_|T], H, N) -> pos(T, H, N+1). %% @doc Format the error code returned by prior call to parse/2 or check/2. -spec format_error([option_spec()], {error, {Reason :: atom(), Data :: term()}} | @@ -151,12 +236,20 @@ format_error(OptSpecList, {error, Reason}) -> format_error(OptSpecList, {missing_required_option, Name}) -> {_Name, Short, Long, _Type, _Help} = lists:keyfind(Name, 1, OptSpecList), lists:flatten(["missing required option: -", [Short], " (", to_string(Long), ")"]); -format_error(_OptSpecList, {invalid_option, OptStr}) -> - lists:flatten(["invalid option: ", to_string(OptStr)]); -format_error(_OptSpecList, {invalid_option_arg, {Name, Arg}}) -> - lists:flatten(["option \'", to_string(Name) ++ "\' has invalid argument: ", to_string(Arg)]); +format_error(OptSpecList, {missing_option_arg, Name}) -> + Opt = lists:keyfind(Name, 1, OptSpecList), + lists:flatten(["missing required option argument: -", [element(2,Opt)], " (", + to_string(Name), ")"]); +format_error(OptSpecList, {invalid_option_arg, {Name, Arg}}) -> + L = case lists:keyfind(Name, 1, OptSpecList) of + {_, Short, undefined, _, _} -> [$-, Short, $ , to_string(Arg)]; + {_, _, Long, _, _} -> ["--", Long, $=, to_string(Arg)] + end, + lists:flatten(["option \'", to_string(Name) ++ "\' has invalid argument: ", L]); format_error(_OptSpecList, {invalid_option_arg, OptStr}) -> lists:flatten(["invalid option argument: ", to_string(OptStr)]); +format_error(_OptSpecList, {invalid_option, OptStr}) -> + lists:flatten(["invalid option: ", to_string(OptStr)]); format_error(_OptSpecList, {Reason, Data}) -> lists:flatten([to_string(Reason), " ", to_string(Data)]). diff --git a/test/getopt_test.erl b/test/getopt_test.erl index a642382..d273e36 100644 --- a/test/getopt_test.erl +++ b/test/getopt_test.erl @@ -13,7 +13,7 @@ -include_lib("eunit/include/eunit.hrl"). --import(getopt, [parse/2, check/2, parse_and_check/2, format_error/2, tokenize/1]). +-import(getopt, [parse/2, check/2, check/3, parse_and_check/3, parse_and_check/2, format_error/2, tokenize/1]). -define(NAME(Opt), element(1, Opt)). -define(SHORT(Opt), element(2, Opt)). @@ -21,7 +21,6 @@ -define(ARG_SPEC(Opt), element(4, Opt)). -define(HELP(Opt), element(5, Opt)). - %%%------------------------------------------------------------------- %%% UNIT TESTS %%%------------------------------------------------------------------- @@ -294,11 +293,119 @@ check_test_() -> {"Check required options", ?_assertEqual({error, {missing_required_option, arg}}, check(OptSpecList, Opts))}, {"Parse arguments and check required options", - ?_assertEqual({error, {missing_required_option, arg}}, parse_and_check(OptSpecList, ""))}, - {"Format error test 1", + ?_assertEqual({error, {missing_required_option, arg}}, parse_and_check(OptSpecList, ""))} + ]. + +format_error_test_() -> + Opts = [ + {help, $h, "help", undefined, "Help string"} + ,{arg, $a, "arg", string, "Required arg"} + ,{xx, $x, "xx", integer, "Required int"} + ,{long, undefined, "long", undefined, "Long undefined"} + ], + [ + {"Format error - missing_required_option arg test1", + ?_assertEqual("missing required option: -a (arg)", + format_error(Opts, {error, {missing_required_option, arg}}))}, + {"Format error - missing_required_option arg test2", ?_assertEqual("missing required option: -a (arg)", - format_error(OptSpecList, {error, {missing_required_option, arg}}))}, - {"Format error test 2", + format_error(Opts, {missing_required_option, arg}))}, + {"Format error - missing_required_option arg test3", ?_assertEqual("missing required option: -a (arg)", - format_error(OptSpecList, {missing_required_option, arg}))} + format_error(Opts, parse_and_check(Opts, "")))}, + {"Format error - missing_option_arg test4", + ?_assertEqual("missing required option argument: -a (arg)", + format_error(Opts, parse_and_check(Opts, "-a")))}, + {"Format error - missing_option_arg test5", + ?_assertEqual("option 'xx' has invalid argument: --xx=bc", + format_error(Opts, parse_and_check(Opts, "--xx=bc -a")))}, + {"Format error - invalid_option_arg test6", + ?_assertEqual("invalid option argument: --long=abc", + format_error(Opts, parse_and_check(Opts, "--long=abc --zz")))}, + {"Format error - invalid_option test7", + ?_assertEqual("invalid option: -z", + format_error(Opts, parse_and_check(Opts, "--arg=abc -z")))}, + {"Format error - invalid_option test8", + ?_assertEqual("invalid option: --zz", + format_error(Opts, parse_and_check(Opts, "--arg=abc --zz")))} + + ]. + +-record(test_rec, {a, b, c = ok, e, help}). + +check_to_record_test_() -> + Options = [{d, to_be_ignored}, {a, 10}, {b, test}, {e,1}, {e,2}, help], + Options2= [{a,10}, {b,test}, {c, cool}, {e,1}, {e,2}, help], + Fun = fun(d, _Old, _Value) -> ignore; + (help, _, true) -> ignore; + (b, _Old, _Value) -> {ok, replaced}; + (e, Old, Value) -> {ok, [Value | Old]}; + (_, _Old, Value) -> {ok, Value} + end, + Fields = record_info(fields, test_rec), + [ + {"Check to_record conversion - throw without validation", + ?_assertException(throw, {field_not_found, d, [a,b,c,e,help]}, + getopt:to_record(Options, Fields, #test_rec{}))}, + {"Check to_record conversion without validation", + ?_assertEqual(#test_rec{a=10, b=test, c=cool, e=2, help=true}, + getopt:to_record(Options2, Fields, #test_rec{}))}, + {"Check to_record conversion with validation", + ?_assertEqual(#test_rec{a=10, b=replaced, c=ok, e=[2,1], help=undefined}, + getopt:to_record(Options, Fields, #test_rec{e=[]}, Fun))} + ]. + +check_help_test_() -> + OptSpecList1 = [{arg, $a, "arg", string, "Required arg"}], + OptSpecList2 = [{help, $h, "help", undefined, "Help string"} | OptSpecList1], + + {ok, {Opts1, _}} = parse(OptSpecList1, ""), + {ok, {Opts2, _}} = parse(OptSpecList2, "-h"), + + [ + {"Check required arg without -h with no help checking", + ?_assertEqual({error, {missing_required_option, arg}}, check(OptSpecList1, Opts1))}, + {"Check required arg with -h with no help checking", + ?_assertEqual({error, {missing_required_option, arg}}, check(OptSpecList2, Opts1))}, + {"Check required arg without -h with no help checking", + ?_assertEqual({error, {missing_required_option, arg}}, check(OptSpecList1, Opts2))}, + {"Check required arg with -h with no help checking", + ?_assertEqual({error, {missing_required_option, arg}}, check(OptSpecList2, Opts2))}, + {"Check required arg without -h with help checking", + ?_assertEqual({error, {missing_required_option, arg}}, check(OptSpecList1, Opts1, [help]))}, + {"Check required arg with -h with help checking", + ?_assertEqual({error, {missing_required_option, arg}}, check(OptSpecList2, Opts1, [help]))}, + {"Check required arg without -h with help checking", + ?_assertEqual({error, {missing_required_option, arg}}, check(OptSpecList1, Opts2, [help]))}, + {"Check required arg with -h with help checking", + ?_assertEqual(help, check(OptSpecList2, Opts2, [help]))}, + + {"Skip required arg check", + ?_assertEqual(ok, check(OptSpecList1, Opts1, [{skip, [arg]}]))}, + {"Check required arg with -h with help checking", + ?_assertEqual(help, check(OptSpecList2, Opts2, [help, {skip, [arg]}]))}, + + {"Parse and check required arg without -h with no help checking", + ?_assertEqual({error, {missing_required_option, arg}}, parse_and_check(OptSpecList1, []))}, + {"Parse and check required arg with -h with no help checking", + ?_assertEqual({error, {missing_required_option, arg}}, parse_and_check(OptSpecList2, []))}, + {"Parse and check required arg without -h with no help checking", + ?_assertEqual({error, {invalid_option, "-h"}}, parse_and_check(OptSpecList1, "-h"))}, + {"Parse and check required arg with -h with no help checking", + ?_assertEqual({error, {missing_required_option, arg}}, parse_and_check(OptSpecList2, "-h"))}, + {"Parse and check required arg without -h with help checking", + ?_assertEqual({error, {missing_required_option, arg}}, parse_and_check(OptSpecList1, [], [help]))}, + {"Parse and check required arg with -h with help checking", + ?_assertEqual({error, {missing_required_option, arg}}, parse_and_check(OptSpecList2, [], [help]))}, + {"Parse and check required arg without -h with help checking", + ?_assertEqual({error, {invalid_option, "-h"}}, parse_and_check(OptSpecList1, "-h", [help]))}, + {"Parse and check required arg with -h with help checking", + ?_assertEqual(help, parse_and_check(OptSpecList2, "-h", [help]))}, + + {"Skip required arg parse and check", + ?_assertEqual({ok, {[],[]}}, parse_and_check(OptSpecList1, [], [{skip, [arg]}]))}, + {"Check required arg parse and check with -h with help checking", + ?_assertEqual(help, parse_and_check(OptSpecList2, "-h", [help, {skip, [arg]}]))} ]. + +