Current section
Files
Jump to
Current section
Files
lib/nexus/parser.ex
defmodule Nexus.Parser do
@moduledoc """
Nexus.Parser provides functionalities to parse raw input strings based on the CLI AST.
This implementation uses functional parser combinators for clean, composable parsing.
"""
alias Nexus.CLI.Command
alias Nexus.CLI.Flag
alias Nexus.Parser.DSL
@type result :: %{
program: atom,
command: list(atom),
flags: %{atom => term},
args: %{atom => term}
}
@doc """
Parses the raw input string based on the given AST.
"""
@spec parse_ast(cli :: Nexus.CLI.t(), input :: String.t() | list(String.t())) ::
{:ok, result} | {:error, list(String.t())}
def parse_ast(%Nexus.CLI{} = cli, input) when is_binary(input) do
case tokenize(input) do
{:ok, []} -> {:error, ["No program specified"]}
{:ok, tokens} -> parse_ast(cli, tokens)
{:error, msg} -> {:error, [msg]}
end
end
def parse_ast(%Nexus.CLI{} = cli, tokens) when is_list(tokens) do
with {:ok, root_cmd, tokens} <- extract_root_cmd_name(tokens),
{:ok, root_ast} <- find_root_or_use_cli(root_cmd, cli),
{:ok, command_path, command_ast, tokens} <- extract_commands(tokens, root_ast, cli),
{:ok, flags, args} <- parse_flags_and_args_with_context(tokens, command_ast.flags, cli.root_flags),
{:ok, help_issued?} <- verify_help_presence(flags),
{:ok, processed_flags} <- process_flags(flags, command_ast.flags ++ cli.root_flags, help: help_issued?),
{:ok, processed_args} <- process_args(args, command_ast.args, help: help_issued?) do
{:ok,
%{
program: cli.name,
command: if(root_ast, do: [root_cmd | Enum.map(command_path, &String.to_atom/1)], else: []),
flags: processed_flags,
args: processed_args
}}
else
{:error, reason} -> {:error, List.wrap(reason)}
end
end
defp tokenize(input) do
input
|> String.trim()
|> String.split(~r/\s+/, trim: true)
|> handle_quoted_strings()
end
defp handle_quoted_strings(tokens) do
tokens
|> Enum.reduce({:ok, [], false, []}, &handle_quoted_string/2)
|> case do
{:ok, acc, false, []} ->
{:ok, Enum.reverse(acc)}
{:ok, _acc, true, _buffer} ->
{:error, "Unclosed quoted string"}
{:error, msg} ->
{:error, [msg]}
end
end
defp handle_quoted_string(token, {:ok, acc, in_quote, buffer}) do
cond do
raw_quoted?(token) -> handle_raw_quoted(token, buffer, in_quote, acc)
String.starts_with?(token, "\"") -> handle_started_quoted(token, acc)
String.ends_with?(token, "\"") and in_quote -> handle_ended_quoted(token, buffer, acc)
in_quote -> {:ok, acc, true, [token | buffer]}
true -> {:ok, [token | acc], in_quote, buffer}
end
end
defp raw_quoted?(token) do
String.starts_with?(token, "\"") and String.ends_with?(token, "\"") and
String.length(token) > 1
end
defp handle_raw_quoted(token, buffer, in_quote, acc) do
unquoted = String.slice(token, 1..-2//1)
{:ok, [unquoted | acc], in_quote, buffer}
end
defp handle_started_quoted(token, acc) do
unquoted = String.trim_leading(token, "\"")
{:ok, acc, true, [unquoted]}
end
defp handle_ended_quoted(token, buffer, acc) do
unquoted = String.trim_trailing(token, "\"")
buffer = Enum.reverse([unquoted | buffer])
combined = Enum.join(buffer, " ")
{:ok, [combined | acc], false, []}
end
defp extract_root_cmd_name([program_name | rest]) do
{:ok, String.to_existing_atom(program_name), rest}
rescue
ArgumentError ->
{:error, "Command '#{program_name}' not found"}
end
defp extract_root_cmd_name([]), do: {:error, "No program specified"}
defp extract_commands(tokens, program_ast, _cli) do
if program_ast do
extract_commands_recursive(tokens, [], program_ast)
else
# No command found, this might be a root flag invocation
{:ok, [], %Command{flags: [], args: []}, tokens}
end
end
defp extract_commands_recursive([token | rest_tokens], command_path, current_ast) do
subcommand_ast =
Enum.find(current_ast.subcommands || [], fn cmd ->
to_string(cmd.name) == token
end)
if subcommand_ast do
extract_commands_recursive(rest_tokens, command_path ++ [token], subcommand_ast)
else
{:ok, command_path, current_ast, [token | rest_tokens]}
end
end
defp extract_commands_recursive([], command_path, current_ast) do
{:ok, command_path, current_ast, []}
end
defp parse_flags_and_args_with_context(tokens, flag_definitions, root_flags) do
all_flags = flag_definitions ++ root_flags
flag_lookup = build_flag_lookup_maps(all_flags)
parse_flags_and_args_with_context_impl(tokens, [{:help_flag, "help", false}], [], flag_lookup)
end
defp parse_flags_and_args_with_context_impl([], flags, args, _flag_definitions) do
{:ok, Enum.reverse(uniq_flag_by_name(flags)), Enum.reverse(args)}
end
defp parse_flags_and_args_with_context_impl([token | rest] = tokens, flags, args, flag_definitions) do
cond do
help_flag_present?(tokens) ->
finish_parsing_with_help(flags, args)
String.starts_with?(token, "--") ->
parse_long_flag_with_context(token, rest, flags, args, flag_definitions)
String.starts_with?(token, "-") and token != "-" ->
parse_short_flag_with_context(token, rest, flags, args, flag_definitions)
true ->
parse_argument_with_context(token, rest, flags, args, flag_definitions)
end
end
defp help_flag_present?(tokens) do
"--help" in tokens or "-h" in tokens
end
defp finish_parsing_with_help(flags, args) do
flags = [{:help_flag, "help", true} | flags]
{:ok, Enum.reverse(uniq_flag_by_name(flags)), Enum.reverse(args)}
end
defp parse_long_flag_with_context(token, rest, flags, args, flag_definitions) do
case DSL.parse(DSL.long_flag_parser(), [token]) do
{:ok, {:flag, :long, name, true}, _} ->
handle_flag_value_consumption(:long, name, true, rest, flags, args, flag_definitions)
{:ok, {:flag, :long, name, value}, _} ->
parse_flags_and_args_with_context_impl(rest, [{:long_flag, name, value} | flags], args, flag_definitions)
{:error, _} ->
parse_flags_and_args_with_context_impl(rest, flags, [token | args], flag_definitions)
end
end
defp parse_short_flag_with_context(token, rest, flags, args, flag_definitions) do
case DSL.parse(DSL.short_flag_parser(), [token]) do
{:ok, {:flag, :short, name, true}, _} ->
handle_flag_value_consumption(:short, name, true, rest, flags, args, flag_definitions)
{:ok, {:flag, :short, name, value}, _} ->
parse_flags_and_args_with_context_impl(rest, [{:short_flag, name, value} | flags], args, flag_definitions)
{:error, _} ->
parse_flags_and_args_with_context_impl(rest, flags, [token | args], flag_definitions)
end
end
defp parse_argument_with_context(token, rest, flags, args, flag_definitions) do
unquoted_arg = DSL.unquote_string(token)
parse_flags_and_args_with_context_impl(rest, flags, [unquoted_arg | args], flag_definitions)
end
defp handle_flag_value_consumption(flag_type, name, _default_value, rest, flags, args, flag_definitions) do
flag_def = find_flag_definition(name, flag_definitions)
case flag_def do
%Flag{type: :boolean} ->
flag_entry = {flag_type_to_atom(flag_type), name, true}
parse_flags_and_args_with_context_impl(rest, [flag_entry | flags], args, flag_definitions)
%Flag{type: type} when type != :boolean and rest != [] ->
[value | remaining_rest] = rest
flag_entry = {flag_type_to_atom(flag_type), name, value}
parse_flags_and_args_with_context_impl(remaining_rest, [flag_entry | flags], args, flag_definitions)
%Flag{type: type} when type != :boolean ->
{:error, "Flag --#{name} expects a #{type} value but none was provided"}
nil ->
flag_entry = {flag_type_to_atom(flag_type), name, true}
parse_flags_and_args_with_context_impl(rest, [flag_entry | flags], args, flag_definitions)
end
end
defp build_flag_lookup_maps(flag_definitions) do
long_map =
Map.new(flag_definitions, fn flag_def ->
{to_string(flag_def.name), flag_def}
end)
short_map =
flag_definitions
|> Enum.filter(& &1.short)
|> Map.new(fn flag_def ->
{to_string(flag_def.short), flag_def}
end)
%{long: long_map, short: short_map}
end
defp find_flag_definition(name, %{long: long_map, short: short_map}) do
Map.get(long_map, name) || Map.get(short_map, name)
end
defp flag_type_to_atom(:long), do: :long_flag
defp flag_type_to_atom(:short), do: :short_flag
defp uniq_flag_by_name(flags) do
Enum.uniq_by(flags, &elem(&1, 1))
end
defp find_root_or_use_cli(name, cli) do
case Enum.find(cli.spec, &(&1.name == name)) do
nil ->
# Check if the name is the CLI itself (program name for root flags)
if name == cli.name and Enum.any?(cli.root_flags) do
# No command, just root flags
{:ok, nil}
else
{:error, "Command '#{name}' not found"}
end
program ->
{:ok, program}
end
end
defp verify_help_presence(flags) when is_list(flags) do
help = Enum.find(flags, &help_flag?/1)
{:ok, if(help, do: elem(help, 2), else: false)}
end
defp help_flag?({_type, "help", _v}), do: true
defp help_flag?({_type, "h", _v}), do: true
defp help_flag?(_), do: false
defp process_flags(_flag_tokens, _defined_flags, help: true) do
{:ok, %{help: true}}
end
defp process_flags(flag_tokens, defined_flags, _help) do
flag_lookup = build_flag_lookup_maps(defined_flags)
case parse_all_flags(flag_tokens, flag_lookup, %{}) do
{:ok, flags} ->
missing_required_flags = list_missing_required_flags(flags, defined_flags)
if Enum.empty?(missing_required_flags) do
non_parsed_flags = list_non_parsed_flags(flags, defined_flags)
{:ok, Map.merge(flags, non_parsed_flags)}
else
{:error, "Missing required flags: #{Enum.join(missing_required_flags, ", ")}"}
end
{:error, reason} ->
{:error, reason}
end
end
defp parse_all_flags([], _defined_flags, acc), do: {:ok, acc}
defp parse_all_flags([flag_token | rest], defined_flags, acc) do
case parse_flag(flag_token, acc, defined_flags) do
{:ok, updated_acc} ->
parse_all_flags(rest, defined_flags, updated_acc)
{:error, reason} ->
{:error, reason}
end
end
defp parse_flag({_flag_type, "help", value}, parsed, _defined) do
{:ok, Map.put(parsed, :help, value)}
end
defp parse_flag({_flag_type, name, value}, parsed, %{long: _, short: _} = flag_lookup) do
flag_def = find_flag_definition(name, flag_lookup)
if flag_def do
case parse_value(value, flag_def.type) do
{:ok, parsed_value} ->
{:ok, Map.put(parsed, flag_def.name, parsed_value)}
{:error, reason} ->
{:error, reason}
end
else
{:ok, parsed}
end
end
defp list_missing_required_flags(parsed, defined) do
defined
|> Enum.filter(fn flag ->
flag.required and not Map.has_key?(parsed, flag.name)
end)
|> Enum.map(&to_string(&1.name))
end
defp list_non_parsed_flags(parsed, defined) do
defined
|> Enum.filter(&(not Map.has_key?(parsed, &1.name)))
|> Map.new(&{&1.name, &1.default})
end
defp parse_value(value, :boolean) when is_boolean(value), do: {:ok, value}
defp parse_value("true", :boolean), do: {:ok, true}
defp parse_value("false", :boolean), do: {:ok, false}
defp parse_value(value, :integer) when is_integer(value), do: {:ok, value}
defp parse_value(value, :integer) do
case Integer.parse(value) do
{int, ""} -> {:ok, int}
_ -> {:error, "Invalid integer value: #{value}"}
end
end
defp parse_value(value, :float) when is_float(value), do: {:ok, value}
defp parse_value(value, :float) do
case Float.parse(value) do
{float, ""} -> {:ok, float}
_ -> {:error, "Invalid float value: #{value}"}
end
end
defp parse_value(value, _), do: {:ok, value}
defp process_args(_arg_tokens, _defined_args, help: true) do
{:ok, %{}}
end
defp process_args(arg_tokens, defined_args, _help) do
case process_args_recursive(arg_tokens, defined_args, %{}) do
{:ok, acc} -> {:ok, acc}
{:error, reason} -> {:error, [reason]}
end
end
defp process_args_recursive([], [], acc), do: {:ok, acc}
defp process_args_recursive([token | _rest], [], _acc) do
{:error, ["Unexpected argument '#{token}' - command does not accept arguments"]}
end
defp process_args_recursive(tokens, [arg_def | rest_args], acc) do
case process_single_arg(tokens, arg_def) do
{:ok, value, rest_tokens} ->
case parse_value(value, arg_def.type) do
{:ok, parsed_value} ->
acc = Map.put(acc, arg_def.name, parsed_value)
process_args_recursive(rest_tokens, rest_args, acc)
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
defp process_single_arg(tokens, arg_def) do
case arg_def.type do
{:list, _type} -> process_list_arg(tokens, arg_def)
{:enum, values_list} -> process_enum_arg(tokens, arg_def, values_list)
_ -> process_default_arg(tokens, arg_def)
end
end
defp process_list_arg(tokens, arg_def) do
if tokens == [] and arg_def.required do
{:error, "Missing required argument '#{arg_def.name}' of type list"}
else
inner_type =
case arg_def.type do
{:list, type} -> type
_ -> :string
end
case parse_list_values(tokens, inner_type, []) do
{:ok, parsed_values} -> {:ok, parsed_values, []}
{:error, reason} -> {:error, reason}
end
end
end
defp parse_list_values([], _type, acc), do: {:ok, Enum.reverse(acc)}
defp parse_list_values([token | rest], type, acc) do
case parse_value(token, type) do
{:ok, parsed_value} ->
parse_list_values(rest, type, [parsed_value | acc])
{:error, reason} ->
{:error, reason}
end
end
defp process_enum_arg([value | rest_tokens], arg_def, values_list) do
if value in Enum.map(values_list, &to_string/1) do
{:ok, value, rest_tokens}
else
{:error,
"Invalid value for argument '#{arg_def.name}': expected one of [#{Enum.join(values_list, ", ")}], got '#{value}'"}
end
end
defp process_enum_arg([], arg_def, _values_list) do
if arg_def.required do
{:error, "Missing required argument '#{arg_def.name}'"}
else
{:ok, nil, []}
end
end
defp process_default_arg([value | rest_tokens], _arg_def) do
{:ok, value, rest_tokens}
end
defp process_default_arg([], arg_def) do
if arg_def.required do
{:error, "Missing required argument '#{arg_def.name}'"}
else
{:ok, nil, []}
end
end
end