gap/gap.erl
2025-10-20 14:17:54 -07:00

174 lines
4.8 KiB
Erlang

% @doc gap = graduated argument parser
%
% This is "graduated" with respect to degree of opinionation. You can use as
% much or as little of this library as you want.
%
% However, more and more advanced functionality (e.g. automated help messages
% for the user) require stronger and stronger assumptions about your program's
% argument structure.
%
% I chose assumptions that are true for the majority of UNIX programs.
%
% Tiers
%
% 1. tokens/1: play around with the xg-tokens escript to see
-module(gap).
-export_type([
token/0
]).
-export([
tokens/1,
parse_stem/2
]).
%% tokenizing
-type token() :: {'--', Token :: string()}
| {'-', Token :: [char()]}
| stdin
| {naked, Token :: string()}.
-spec tokens(Args) -> Tokens
when Args :: [string()],
Tokens :: [token()].
% @doc
% Assumptions:
% 1. -- means "remaining args are verbatim"
% 2. empty - maps to atom 'stdin'
% 3. -xyz is equivalent to -x -y -z
%
% The only opinionation here is in the treatment of a naked "--" arg:
%
% tokens(["-foo", "-", "--bar", "--", "--baz", "-" "quux"]) ->
% [{'-', $f},
% {'-', $o},
% {'-', $o},
% stdin,
% {'--', "bar"},
% {naked, "--baz"},
% {naked, "-"},
% {naked, "quux"}].
% "--" -> parse rest verbatim
tokens(["--" | Rest]) ->
Nekkid = fun(Token) -> {naked, Token} end,
lists:map(Nekkid, Rest);
% "--option" -> normal
tokens(["--" ++ Token | Rest]) ->
[{'--', Token} | tokens(Rest)];
% "-" -> stdin
tokens(["-" | Rest]) ->
[stdin | tokens(Rest)];
% "-xyz" -> {'-', $x}, {'-', $y}, {'-', $z}]
tokens(["-" ++ Chars | Rest]) ->
Optize = fun(Char) -> {'-', Char} end,
Tokens = lists:map(Optize, Chars),
Tokens ++ tokens(Rest);
tokens([Token | Rest]) ->
[{naked, Token} | tokens(Rest)];
tokens([]) ->
[].
%% parsing inputs
-type aritee() :: non_neg_integer() | infinity.
-type spec_stem() :: {spec_stem, OptionSpecs :: [spec_opt()],
LeafSpecs :: [spec_leaf()]}.
-type spec_opt() :: {spec_opt, LongName :: string(),
ShortName :: char(),
Arity :: aritee()}.
-type spec_leaf() :: spec_args()
| spec_cmd().
-type spec_args() :: {spec_args, Arity :: aritee()}.
-type spec_cmd() :: {spec_cmd, Name :: string(),
Subtree :: spec_stem()}.
%% parsing outputs
-type stem() :: {stem, Opts :: [opt()],
MaybeLeaf :: none | {value, leaf()}}.
-type opt() :: {opt, LongName :: string(),
Args :: args()}.
-type leaf() :: args()
| cmd().
-type args() :: {args, [token()]}.
-type cmd() :: {cmd, Name :: string(),
Subtree :: stem()}.
-spec parse_stem(Spec, Tokens) -> {Stem, Rest}
when Spec :: spec_stem(),
Tokens :: [token()],
Stem :: stem(),
Rest :: Tokens.
% @doc
% This is the beginning of opinionation
%
% Assumes your program's command syntax is
%
% Stem :: rootcmd GlobalOptions SubTree
%
% where
% GlobalOptions :: -one --or -more --global --options
% SubTree :: Args | SubCmd
% Args :: ["specified", "number", "of", "string arguments"]
% SubCmd ::
parse_stem({spec_stem, OptSpecs, SubtreeSpec}, Args0) ->
{Opts, Args1} = parse_opts(OptSpecs, Args0),
{Subtree, Args2} = parse_subtree(SubtreeSpec, Args1),
{{tree, Opts, Subtree}, Args2}.
-spec parse_opts(OptSpecs, Tokens) -> {Opts, Rest}
when OptSpecs :: [spec_opt()],
Tokens :: [token()],
Opts :: [opt()],
Rest :: Tokens.
parse_opts(Specs, Tokens) ->
parse_opts(Specs, Tokens, []).
%% parse options
%% out of specs, move on
% match --foo -> consume args
match_opt({spec_opt, LongName, _, Aritee}, [{'--', LongName} | Rest0]) ->
case match_args(Aritee, Rest0) of
{match, OptArgs, Rest1} -> {match, {opt, LongName, OptArgs}, Rest1};
Fail -> Fail
end;
% match -f -> consume args
match_opt({spec_opt, LongName, ShortName, Aritee}, [{'-', ShortName} | Rest0]) ->
case match_args(Aritee, Rest0) of
{match, OptArgs, Rest1} -> {match, {opt, LongName, OptArgs}, Rest1};
Fail -> Fail
end;
% not a match
match_opt(_, Tokens) ->
{fail, Tokens}.
match_args(infinity, Rest0) ->
{match, {args, Rest0}, []};
match_args(N, [Head | Rest0]) when is_integer(N), N >= 1 ->
case match_args(N-1, Rest0) of
{match, {args, TailArgs}, Rest1} -> {match, {args, [Head | TailArgs]}, Rest1};
Fail -> Fail
end;
match_args(0, List) ->
{match, {args, []}, List};
match_args(_, List) ->
{fail, List}.
parse_subtree(_, _) -> error(nyi).