Packages

Low-level command line option parsing for making CLI tooling in Gleam!

Current section

Files

Jump to
hoist src hoist.erl
Raw

src/hoist.erl

-module(hoist).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/hoist.gleam").
-export([new_flag/1, with_long_alias/2, with_short_alias/2, as_toggle/1, as_count/1, validate_flag_specs/1, parse_with_hook/4, parse/2]).
-export_type([parse_error/1, flag_kind/0, flag_spec/0, validated_flag_specs/0, flag_spec_validation_error/0, flag/0, args/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
-type parse_error(DRV) :: {unknown_flag, binary()} |
{value_not_provided, binary()} |
{value_not_supported, binary(), binary()} |
{custom_error, DRV}.
-type flag_kind() :: value_kind | toggle_kind | count_kind.
-opaque flag_spec() :: {flag_spec,
binary(),
gleam@set:set(binary()),
gleam@set:set(binary()),
flag_kind()}.
-opaque validated_flag_specs() :: {validated_flag_specs,
gleam@dict:dict(binary(), flag_spec()),
gleam@dict:dict(binary(), flag_spec())}.
-type flag_spec_validation_error() :: {empty_name, flag_spec()} |
{invalid_name_or_alias, binary(), flag_spec()} |
{invalid_short_alias, binary(), flag_spec()}.
-type flag() :: {value_flag, binary(), binary()} |
{toggle_flag, binary()} |
{count_flag, binary(), integer()}.
-type args() :: {args, list(binary()), list(flag())}.
-file("src/hoist.gleam", 19).
-spec replace_error_flag_name(parse_error(DRW), binary()) -> parse_error(DRW).
replace_error_flag_name(Error, Flag_name) ->
case Error of
{custom_error, _} ->
Error;
{unknown_flag, _} ->
Error;
{value_not_provided, _} ->
{value_not_provided, Flag_name};
{value_not_supported, _, Given} ->
{value_not_supported, Flag_name, Given}
end.
-file("src/hoist.gleam", 56).
?DOC(" Creates a new flag with the given long name, e.g. `--name`. Case sensitive.\n").
-spec new_flag(binary()) -> flag_spec().
new_flag(Name) ->
{flag_spec, Name, gleam@set:new(), gleam@set:new(), value_kind}.
-file("src/hoist.gleam", 64).
?DOC(
" Adds a long alias for a command, e.g. `--name, --first-name`.\n"
"\n"
" A flag can have multiple long aliases. These are stored in a\n"
" set and will be deduplicated. Case sensitive.\n"
).
-spec with_long_alias(flag_spec(), binary()) -> flag_spec().
with_long_alias(Flag, Alias) ->
{flag_spec,
erlang:element(2, Flag),
gleam@set:insert(erlang:element(3, Flag), Alias),
erlang:element(4, Flag),
erlang:element(5, Flag)}.
-file("src/hoist.gleam", 72).
?DOC(
" Adds a short alias for a command, e.g. `--name, -n`.\n"
"\n"
" A flag can have multiple short aliases. These are stored in a\n"
" set and will be deduplicated. Case sensitive.\n"
).
-spec with_short_alias(flag_spec(), binary()) -> flag_spec().
with_short_alias(Flag, Short) ->
{flag_spec,
erlang:element(2, Flag),
erlang:element(3, Flag),
gleam@set:insert(erlang:element(4, Flag), Short),
erlang:element(5, Flag)}.
-file("src/hoist.gleam", 79).
?DOC(
" Designates this flag as a toggle-kind flag. Generally used to enable\n"
" or disable an option, e.g. `--colour`, `--no-colour`. Does not\n"
" receive a value.\n"
).
-spec as_toggle(flag_spec()) -> flag_spec().
as_toggle(Flag) ->
{flag_spec,
erlang:element(2, Flag),
erlang:element(3, Flag),
erlang:element(4, Flag),
toggle_kind}.
-file("src/hoist.gleam", 85).
?DOC(
" Designates this flag as a count-kind flag. Often used for verbosity levels,\n"
" e.g. `-vvv` would have a count of 3.\n"
).
-spec as_count(flag_spec()) -> flag_spec().
as_count(Flag) ->
{flag_spec,
erlang:element(2, Flag),
erlang:element(3, Flag),
erlang:element(4, Flag),
count_kind}.
-file("src/hoist.gleam", 96).
-spec flag_name_validation_regex() -> gleam@regexp:regexp().
flag_name_validation_regex() ->
Pattern@1 = case gleam@regexp:compile(
<<"^[a-zA-Z0-9][a-zA-Z0-9_-]*$"/utf8>>,
{options, false, false}
) of
{ok, Pattern} -> Pattern;
_assert_fail ->
erlang:error(#{gleam_error => let_assert,
message => <<"Pattern match failed, no pattern matched the value."/utf8>>,
file => <<?FILEPATH/utf8>>,
module => <<"hoist"/utf8>>,
function => <<"flag_name_validation_regex"/utf8>>,
line => 97,
value => _assert_fail,
start => 2862,
'end' => 3015,
pattern_start => 2873,
pattern_end => 2884})
end,
Pattern@1.
-file("src/hoist.gleam", 106).
-spec validate_single_flag_spec(flag_spec(), gleam@regexp:regexp()) -> {ok,
flag_spec()} |
{error, flag_spec_validation_error()}.
validate_single_flag_spec(Flag_spec, Validation_regex) ->
All_long_names = [erlang:element(2, Flag_spec) |
gleam@set:to_list(erlang:element(3, Flag_spec))],
Long_name_validation_result = gleam@list:try_each(
All_long_names,
fun(Name) -> case Name of
<<""/utf8>> ->
{error, {empty_name, Flag_spec}};
Name@1 ->
case gleam@regexp:check(Validation_regex, Name@1) of
true ->
{ok, nil};
false ->
{error, {invalid_name_or_alias, Name@1, Flag_spec}}
end
end end
),
gleam@result:'try'(
Long_name_validation_result,
fun(_) ->
Short_name_validation_result = gleam@list:try_each(
gleam@set:to_list(erlang:element(4, Flag_spec)),
fun(Short) -> case string:length(Short) of
0 ->
{error, {empty_name, Flag_spec}};
1 ->
case gleam@regexp:check(Validation_regex, Short) of
true ->
{ok, nil};
false ->
{error,
{invalid_short_alias, Short, Flag_spec}}
end;
_ ->
{error, {invalid_short_alias, Short, Flag_spec}}
end end
),
gleam@result:'try'(
Short_name_validation_result,
fun(_) -> {ok, Flag_spec} end
)
end
).
-file("src/hoist.gleam", 156).
?DOC(
" Validates flag specs.\n"
"\n"
" Ensures that every defined long flag and its aliases:\n"
" - Have at least a one character name\n"
" - Start with a letter or number\n"
" - Only contain letters, numbers, hyphens and underscores\n"
"\n"
" Ensures that every defined short flag:\n"
" - Has exactly one character\n"
" - Is a letter or number\n"
).
-spec validate_flag_specs(list(flag_spec())) -> {ok, validated_flag_specs()} |
{error, list(flag_spec_validation_error())}.
validate_flag_specs(Flags) ->
{_, Flag_validation_errors} = begin
_pipe = gleam@list:map(
Flags,
fun(_capture) ->
validate_single_flag_spec(
_capture,
flag_name_validation_regex()
)
end
),
gleam@result:partition(_pipe)
end,
case Flag_validation_errors of
[] ->
{Long_flags, Short_flags} = gleam@list:fold(
Flags,
{maps:new(), maps:new()},
fun(Dicts, Flag) ->
{Lf, Stl} = Dicts,
Lf@1 = gleam@dict:insert(Lf, erlang:element(2, Flag), Flag),
Lf@2 = gleam@set:fold(
erlang:element(3, Flag),
Lf@1,
fun(Curr, Alias) ->
gleam@dict:insert(Curr, Alias, Flag)
end
),
Stl@1 = gleam@set:fold(
erlang:element(4, Flag),
Stl,
fun(Curr@1, Short) ->
gleam@dict:insert(Curr@1, Short, Flag)
end
),
{Lf@2, Stl@1}
end
),
{ok, {validated_flag_specs, Long_flags, Short_flags}};
Errors ->
{error, Errors}
end.
-file("src/hoist.gleam", 546).
-spec upsert_flag(flag(), list(flag())) -> list(flag()).
upsert_flag(New_flag, Flags) ->
_pipe = gleam@list:filter(
Flags,
fun(Input) ->
erlang:element(2, Input) /= erlang:element(2, New_flag)
end
),
lists:append(_pipe, [New_flag]).
-file("src/hoist.gleam", 551).
-spec upsert_count_flag(flag_spec(), list(flag())) -> list(flag()).
upsert_count_flag(Flag_spec, Flags) ->
Existing_flag_count_result = gleam@list:find_map(
Flags,
fun(Input) -> case Input of
{count_flag, N, Count} when N =:= erlang:element(2, Flag_spec) ->
{ok, Count};
_ ->
{error, nil}
end end
),
Count@2 = case Existing_flag_count_result of
{ok, Count@1} ->
Count@1 + 1;
{error, _} ->
1
end,
upsert_flag({count_flag, erlang:element(2, Flag_spec), Count@2}, Flags).
-file("src/hoist.gleam", 318).
-spec handle_positional(
binary(),
list(binary()),
validated_flag_specs(),
args(),
DTE,
fun((DTE, binary(), args(), validated_flag_specs()) -> {ok,
{DTE, validated_flag_specs()}} |
{error, DTF})
) -> {DTE, {ok, args()} | {error, parse_error(DTF)}}.
handle_positional(
New_arg,
Remaining_input,
Flag_specs,
State,
Hook_state,
Parse_hook
) ->
New_args = {args,
lists:append(erlang:element(2, State), [New_arg]),
erlang:element(3, State)},
case Parse_hook(Hook_state, New_arg, New_args, Flag_specs) of
{error, Error} ->
{Hook_state, {error, {custom_error, Error}}};
{ok, {New_hook_state, New_flag_specs}} ->
do_parse(
Remaining_input,
New_flag_specs,
{ok, New_args},
New_hook_state,
Parse_hook
)
end.
-file("src/hoist.gleam", 249).
-spec do_parse(
list(binary()),
validated_flag_specs(),
{ok, args()} | {error, parse_error(DST)},
DSX,
fun((DSX, binary(), args(), validated_flag_specs()) -> {ok,
{DSX, validated_flag_specs()}} |
{error, DST})
) -> {DSX, {ok, args()} | {error, parse_error(DST)}}.
do_parse(Remaining_input, Flag_specs, State, Hook_state, Parse_hook) ->
case State of
{error, Error} ->
{Hook_state, {error, Error}};
{ok, State@1} ->
case Remaining_input of
[] ->
{Hook_state, {ok, State@1}};
[<<"--"/utf8>> | Rest] ->
Arguments = lists:append(erlang:element(2, State@1), Rest),
{Hook_state,
{ok, {args, Arguments, erlang:element(3, State@1)}}};
[<<"--"/utf8, Flag_name/binary>> | Rest@1] ->
handle_long_flag(
Flag_name,
Rest@1,
Flag_specs,
State@1,
Hook_state,
Parse_hook
);
[<<"-"/utf8>> = Arg | Rest@2] ->
handle_positional(
Arg,
Rest@2,
Flag_specs,
State@1,
Hook_state,
Parse_hook
);
[<<"-"/utf8, Flag_names/binary>> | Rest@3] ->
handle_short_flag(
Flag_names,
Rest@3,
Flag_specs,
State@1,
Hook_state,
Parse_hook
);
[Arg@1 | Rest@4] ->
handle_positional(
Arg@1,
Rest@4,
Flag_specs,
State@1,
Hook_state,
Parse_hook
)
end
end.
-file("src/hoist.gleam", 238).
?DOC(
" Similar to [`parse`](#parse) but allows passing a custom hook that gets called\n"
" after a positional argument is parsed.\n"
"\n"
" The hook accepts the current hook state, the most recent positional argument,\n"
" the currently parsed arguments and flags, and the available flag specs.\n"
"\n"
" The hook can return a new set of flags to be used for parsing the next argument,\n"
" along with a new state value.\n"
"\n"
" Useful when the flags available for parsing depend on the value of a previous\n"
" positional argument.\n"
).
-spec parse_with_hook(
list(binary()),
validated_flag_specs(),
DSL,
fun((DSL, binary(), args(), validated_flag_specs()) -> {ok,
{DSL, validated_flag_specs()}} |
{error, DSM})
) -> {DSL, {ok, args()} | {error, parse_error(DSM)}}.
parse_with_hook(Input, Flags, Hook_state, Parse_hook) ->
State = {args, [], []},
do_parse(Input, Flags, {ok, State}, Hook_state, Parse_hook).
-file("src/hoist.gleam", 215).
?DOC(
" Parses positional args and flags from a list of args.\n"
"\n"
" Note: this function does not sanitise input in any way. If you require\n"
" sanitisation, e.g. removal of whitespace-only arguments, you must handle\n"
" that yourself.\n"
).
-spec parse(list(binary()), validated_flag_specs()) -> {ok, args()} |
{error, parse_error(any())}.
parse(Input, Flags) ->
{_, Outcome} = parse_with_hook(
Input,
Flags,
nil,
fun(State, _, _, Flags@1) -> {ok, {State, Flags@1}} end
),
Outcome.
-file("src/hoist.gleam", 343).
-spec handle_long_flag(
binary(),
list(binary()),
validated_flag_specs(),
args(),
DTM,
fun((DTM, binary(), args(), validated_flag_specs()) -> {ok,
{DTM, validated_flag_specs()}} |
{error, DTN})
) -> {DTM, {ok, args()} | {error, parse_error(DTN)}}.
handle_long_flag(
Flag_name,
Remaining_input,
Flag_specs,
State,
Hook_state,
Parse_hook
) ->
{Parsed_flag_name, Has_equals_value, Remaining_input@1} = case gleam@string:split_once(
Flag_name,
<<"="/utf8>>
) of
{ok, {Name, Value}} ->
{Name, true, [Value | Remaining_input]};
{error, _} ->
{Flag_name, false, Remaining_input}
end,
case gleam_stdlib:map_get(erlang:element(2, Flag_specs), Parsed_flag_name) of
{error, _} ->
{Hook_state, {error, {unknown_flag, Parsed_flag_name}}};
{ok, Flag_spec} ->
case {erlang:element(5, Flag_spec), Has_equals_value} of
{value_kind, _} ->
case Remaining_input@1 of
[Value@1 | Rest] ->
do_parse(
Rest,
Flag_specs,
{ok,
{args,
erlang:element(2, State),
lists:append(
erlang:element(3, State),
[{value_flag,
erlang:element(2, Flag_spec),
Value@1}]
)}},
Hook_state,
Parse_hook
);
[] ->
{Hook_state,
{error, {value_not_provided, Parsed_flag_name}}}
end;
{toggle_kind, true} ->
Given = gleam@result:unwrap(
gleam@list:first(Remaining_input@1),
<<""/utf8>>
),
Parse_error = {value_not_supported, Parsed_flag_name, Given},
{Hook_state, {error, Parse_error}};
{count_kind, true} ->
Given@1 = gleam@result:unwrap(
gleam@list:first(Remaining_input@1),
<<""/utf8>>
),
Parse_error@1 = {value_not_supported,
Parsed_flag_name,
Given@1},
{Hook_state, {error, Parse_error@1}};
{toggle_kind, false} ->
Flag = {toggle_flag, erlang:element(2, Flag_spec)},
Flags = upsert_flag(Flag, erlang:element(3, State)),
do_parse(
Remaining_input@1,
Flag_specs,
{ok, {args, erlang:element(2, State), Flags}},
Hook_state,
Parse_hook
);
{count_kind, false} ->
Flags@1 = upsert_count_flag(
Flag_spec,
erlang:element(3, State)
),
do_parse(
Remaining_input@1,
Flag_specs,
{ok, {args, erlang:element(2, State), Flags@1}},
Hook_state,
Parse_hook
)
end
end.
-file("src/hoist.gleam", 434).
-spec handle_short_flag(
binary(),
list(binary()),
validated_flag_specs(),
args(),
DTU,
fun((DTU, binary(), args(), validated_flag_specs()) -> {ok,
{DTU, validated_flag_specs()}} |
{error, DTV})
) -> {DTU, {ok, args()} | {error, parse_error(DTV)}}.
handle_short_flag(
Flag_names,
Remaining_input,
Flag_specs,
State,
Hook_state,
Parse_hook
) ->
Graphemes = gleam@string:to_graphemes(Flag_names),
case Graphemes of
[] ->
do_parse(
Remaining_input,
Flag_specs,
{ok, State},
Hook_state,
Parse_hook
);
[Short_flag | Rest_flags] ->
case gleam_stdlib:map_get(erlang:element(3, Flag_specs), Short_flag) of
{error, _} ->
{Hook_state, {error, {unknown_flag, Short_flag}}};
{ok, Flag} ->
case Rest_flags of
[] ->
{Hook_state@1, Result} = handle_long_flag(
erlang:element(2, Flag),
Remaining_input,
Flag_specs,
State,
Hook_state,
Parse_hook
),
Result@1 = gleam@result:map_error(
Result,
fun(_capture) ->
replace_error_flag_name(
_capture,
Short_flag
)
end
),
{Hook_state@1, Result@1};
[<<"="/utf8>> | Value_graphemes] ->
Value = erlang:list_to_binary(Value_graphemes),
case erlang:element(5, Flag) of
toggle_kind ->
Error = {value_not_supported,
Short_flag,
Value},
{Hook_state, {error, Error}};
count_kind ->
Error = {value_not_supported,
Short_flag,
Value},
{Hook_state, {error, Error}};
value_kind ->
Flag@1 = {value_flag,
erlang:element(2, Flag),
Value},
Flags = lists:append(
erlang:element(3, State),
[Flag@1]
),
do_parse(
Remaining_input,
Flag_specs,
{ok,
{args,
erlang:element(2, State),
Flags}},
Hook_state,
Parse_hook
)
end;
Rest_flags@1 ->
case erlang:element(5, Flag) of
value_kind ->
Flag@2 = {value_flag,
erlang:element(2, Flag),
erlang:list_to_binary(Rest_flags@1)},
Flags@1 = upsert_flag(
Flag@2,
erlang:element(3, State)
),
do_parse(
Remaining_input,
Flag_specs,
{ok,
{args,
erlang:element(2, State),
Flags@1}},
Hook_state,
Parse_hook
);
toggle_kind ->
Flag@3 = {toggle_flag,
erlang:element(2, Flag)},
Flags@2 = upsert_flag(
Flag@3,
erlang:element(3, State)
),
handle_short_flag(
erlang:list_to_binary(Rest_flags@1),
Remaining_input,
Flag_specs,
{args,
erlang:element(2, State),
Flags@2},
Hook_state,
Parse_hook
);
count_kind ->
handle_short_flag(
erlang:list_to_binary(Rest_flags@1),
Remaining_input,
Flag_specs,
{args,
erlang:element(2, State),
upsert_count_flag(
Flag,
erlang:element(3, State)
)},
Hook_state,
Parse_hook
)
end
end
end
end.