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 manual tokenization and parsing without parser combinators.
"""
alias Nexus.CLI.Flag
@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
with {:ok, tokens} <- tokenize(input) do
parse_ast(cli, tokens)
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(root_cmd, cli.spec),
{:ok, command_path, command_ast, tokens} <- extract_commands(tokens, root_ast),
{:ok, flags, args} <- parse_flags_and_args(tokens),
{:ok, help_issued?} <- verify_help_presence(flags),
{:ok, processed_flags} <- process_flags(flags, command_ast.flags, help: help_issued?),
{:ok, processed_args} <- process_args(args, command_ast.args, help: help_issued?) do
{:ok,
%{
program: cli.name,
command: [root_cmd | Enum.map(command_path, &String.to_existing_atom/1)],
flags: processed_flags,
args: processed_args
}}
else
{:error, reason} -> {:error, List.wrap(reason)}
end
end
## Tokenization Functions
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
## Extraction Functions
defp extract_root_cmd_name([program_name | rest]) do
{:ok, String.to_existing_atom(program_name), rest}
end
defp extract_root_cmd_name([]), do: {:error, "No program specified"}
defp extract_commands(tokens, program_ast) do
extract_commands(tokens, [], program_ast)
end
defp extract_commands([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
# Found a subcommand, add it to the path and continue
extract_commands(rest_tokens, command_path ++ [token], subcommand_ast)
else
{:ok, command_path, current_ast, [token | rest_tokens]}
end
end
defp extract_commands([], command_path, current_ast) do
# No more tokens
{:ok, command_path, current_ast, []}
end
## Lookup Functions
defp find_root(name, ast) do
case Enum.find(ast, &(&1.name == name)) do
nil -> {:error, "Command '#{name}' not found"}
program -> {:ok, program}
end
end
## Parsing Flags and Arguments
defp parse_flags_and_args(tokens) do
parse_flags_and_args(tokens, [{:help_flag, "help", false}], [])
end
defp parse_flags_and_args([], flags, args) do
{:ok, Enum.reverse(uniq_flag_by_name(flags)), Enum.reverse(args)}
end
defp parse_flags_and_args([token | rest] = tokens, flags, args) do
cond do
"--help" in tokens or "-h" in tokens ->
flags = [{:help_flag, "help", true} | flags]
{:ok, Enum.reverse(uniq_flag_by_name(flags)), Enum.reverse(args)}
String.starts_with?(token, "--") ->
# Long flag
parse_long_flag(token, rest, flags, args)
String.starts_with?(token, "-") and token != "-" ->
# Short flag
parse_short_flag(token, rest, flags, args)
true ->
# Argument
parse_flags_and_args(rest, flags, [token | args])
end
end
defp uniq_flag_by_name(flags) do
Enum.uniq_by(flags, &elem(&1, 1))
end
defp parse_long_flag(token, rest, flags, args) do
case String.split(token, "=", parts: 2) do
[flag] ->
flag_name = String.trim_leading(flag, "--")
parse_flags_and_args(rest, [{:long_flag, flag_name, true} | flags], args)
[flag, value] ->
flag_name = String.trim_leading(flag, "--")
parse_flags_and_args(rest, [{:long_flag, flag_name, value} | flags], args)
end
end
defp parse_short_flag(token, rest, flags, args) do
case String.split(token, "=", parts: 2) do
[flag] ->
flag_name = String.trim_leading(flag, "-")
parse_flags_and_args(rest, [{:short_flag, flag_name, true} | flags], args)
[flag, value] ->
flag_name = String.trim_leading(flag, "-")
parse_flags_and_args(rest, [{:short_flag, flag_name, value} | flags], args)
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?(_), do: false
## Processing Flags
defp process_flags(_flag_tokens, _defined_flags, help: true) do
{:ok, %{help: true}}
end
defp process_flags(flag_tokens, defined_flags, _help) do
flags = Enum.reduce(flag_tokens, %{}, &parse_flag(&1, &2, defined_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
end
defp defined_flag?(name, %Flag{short: nil} = flag), do: name == to_string(flag.name)
defp defined_flag?(name, %Flag{} = flag) do
to_string(flag.name) == name or to_string(flag.short) == name
end
defp parse_flag({_flag_type, "help", value}, parsed, _defined) do
Map.put(parsed, :help, value)
end
defp parse_flag({_flag_type, name, value}, parsed, defined) do
flag_def = Enum.find(defined, &defined_flag?(name, &1))
if flag_def do
parsed_value = parse_value(value, flag_def.type)
Map.put(parsed, flag_def.name, parsed_value)
else
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(&Atom.to_string/1)
end
defp list_non_parsed_flags(parsed, defined) do
defined
|> Enum.filter(&(not Map.has_key?(parsed, &1.name)))
|> Enum.map(&{&1.name, &1.default})
|> Map.new()
end
defp parse_value(value, :boolean) when is_boolean(value), do: value
defp parse_value("true", :boolean), do: true
defp parse_value("false", :boolean), do: false
defp parse_value(value, :integer) when is_integer(value), do: value
defp parse_value(value, :integer) do
case Integer.parse(value) do
{int, ""} -> int
_ -> raise ArgumentError, "Invalid integer value: #{value}"
end
end
defp parse_value(value, :float) when is_float(value), do: value
defp parse_value(value, :float) do
case Float.parse(value) do
{float, ""} -> float
_ -> raise ArgumentError, "Invalid float value: #{value}"
end
end
defp parse_value(value, _), do: value
## Processing Arguments
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(_tokens, [], acc) do
{:ok, acc}
end
defp process_args_recursive(tokens, [arg_def | rest_args], acc) do
case process_single_arg(tokens, arg_def) do
{:ok, value, rest_tokens} ->
acc = Map.put(acc, arg_def.name, parse_value(value, arg_def.type))
process_args_recursive(rest_tokens, rest_args, acc)
{: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
{:ok, Enum.map(tokens, &parse_value(&1, arg_def)), []}
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