diff --git a/.formatter.exs b/.formatter.exs index 45615ee..1a1419a 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,15 @@ +local_without_parens = [ + defcommand: 2, + subcommand: 2, + value: 2, + flag: 2, + short: 1, + description: 1 +] + [ + import_deps: [:nimble_parsec], inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], - export: [locals_without_parens: [defcommand: 2]], - locals_without_parens: [defcommand: 2] + export: [locals_without_parens: local_without_parens], + locals_without_parens: local_without_parens ] diff --git a/README.md b/README.md index 12bec71..a1eb7da 100644 --- a/README.md +++ b/README.md @@ -21,33 +21,22 @@ An `Elixir` library to write command line apps in a cleaner and elegant way! ## Example -```elixir dark +```elixir defmodule MyCLI do - use Nexus + @moduledoc "This will be used into as help" - defcommand :ping, type: :null, doc: "Answers 'pong'" - defcommand :fizzbuzz, type: :integer, required: true, doc: "Plays fizzbuzz" - defcommand :mode, type: {:enum, ~w[fast slow]a}, required: true, doc: "Defines the command mode" + use Nexus.CLI - @impl Nexus.CLI - # no input as type == :null - def handle_input(:ping), do: IO.puts("pong") + defcommand :fizzbuzz do + description "Plays fizzbuzz - this will also be used as help" - @impl Nexus.CLI - # input can be named to anything - @spec handle_input(atom, input) :: :ok - when input: Nexus.Command.Input.t() - def handle_input(:fizzbuzz, %{value: value}) do - cond do - rem(value, 3) == 0 -> IO.puts("fizz") - rem(value, 5) == 0 -> IO.puts("buzz") - rem(value, 3) == 0 and rem(value, 5) == 0 -> IO.puts("fizzbuzz") - true -> IO.puts value - end + value :integer, required: true end - def handle_input(:mode, %{value: :fast), do: IO.puts "Hare" - def handle_input(:mode, %{value: :slow), do: IO.puts "Tortoise" + @impl Nexus.CLI + def handle_input(:fizzbuzz, %{args: [input]}) do + # plays fizzbuzz + end end ``` diff --git a/examples/escript/example.ex b/examples/escript/example.ex index f0bd61d..7a1a00f 100644 --- a/examples/escript/example.ex +++ b/examples/escript/example.ex @@ -13,14 +13,34 @@ defmodule Escript.Example do from the `main/1` escript funciton, as can seen below. """ - use Nexus + use Nexus.CLI - defcommand :foo, required: true, type: :string, doc: "Command that receives a string as argument and prints it." - defcommand :fizzbuzz, type: {:enum, ~w(fizz buzz)a}, doc: "Fizz bUZZ", required: true + defcommand :foo do + description "Command that receives a string as argument and prints it." - defcommand :foo_bar, type: :null, doc: "Teste" do - defcommand :foo, default: "hello", doc: "Hello" - defcommand :bar, default: "hello", doc: "Hello" + value :string, required: true + end + + defcommand :fizzbuzz do + description "Fizz bUZZ" + + value {:enum, ~w(fizz buzz)a}, required: true + end + + defcommand :foo_bar do + description "Teste" + + subcommand :foo do + description "hello" + + value :string, required: false, default: "hello" + end + + subcommand :bar do + description "hello" + + value :string, required: false, default: "hello" + end end @impl true @@ -49,8 +69,5 @@ defmodule Escript.Example do :ok end - Nexus.help() - Nexus.parse() - - defdelegate main(args), to: __MODULE__, as: :run + # defdelegate main(args), to: __MODULE__, as: :run end diff --git a/examples/file_management.ex b/examples/file_management.ex new file mode 100644 index 0000000..4481a41 --- /dev/null +++ b/examples/file_management.ex @@ -0,0 +1,127 @@ +defmodule MyCLI do + @moduledoc """ + MyCLI provides file operations such as copy, move, and delete using the Nexus.CLI DSL. + """ + + use Nexus.CLI, otp_app: :nexus_cli + + defcommand :file do + description "Performs file operations such as copy, move, and delete." + + subcommand :copy do + description "Copies files from source to destination." + + value :string, required: true, as: :source + value :string, required: true, as: :dest + + flag :level do + value :integer, required: false + end + + flag :verbose do + short :v + description "Enables verbose output." + end + + flag :recursive do + short :rc + description "Copies directories recursively." + end + end + + subcommand :move do + description "Moves files from source to destination." + + value :string, required: true, as: :source + value :string, required: true, as: :dest + + flag :force do + short :f + description "Forces the move without confirmation." + end + + flag :verbose do + short :v + description "Enables verbose output." + end + end + + subcommand :delete do + description "Deletes specified files or directories." + + value {:list, :string}, required: true, as: :targets + + flag :force do + short :f + description "Forces deletion without confirmation." + end + + flag :recursive do + short :rc + description "Deletes directories recursively." + end + + flag :verbose do + short :v + description "Enables verbose output." + end + end + end + + @impl Nexus.CLI + def handle_input([:file, :copy], %{args: args, flags: flags}) do + if flags.verbose do + IO.puts("Copying from #{args.source} to #{args.dest}") + end + + if flags.recursive do + IO.puts("Recursive copy enabled") + end + + # Implement actual copy logic here + IO.puts("Copied #{args.source} to #{args.dest}") + :ok + end + + def handle_input([:file, :move], %{args: args, flags: flags}) do + if flags.verbose do + IO.puts("Moving from #{args.source} to #{args.dest}") + end + + if flags.force do + IO.puts("Force move enabled") + end + + # Implement actual move logic here + IO.puts("Moved #{args.source} to #{args.dest}") + :ok + end + + def handle_input([:file, :delete], %{args: args, flags: flags}) do + if flags.verbose do + IO.puts("Deleting targets: #{Enum.join(args.targets, ", ")}") + end + + if flags.recursive do + IO.puts("Recursive delete enabled") + end + + if flags.force do + IO.puts("Force delete enabled") + end + + # Implement actual delete logic here + Enum.each(args.targets, fn target -> + IO.puts("Deleted #{target}") + end) + + :ok + end + + def handle_input(command, input) do + IO.puts("Unknown command or invalid parameters") + IO.inspect(command, label: "CMD") + IO.inspect(input, label: "INPUT") + :error + end +end diff --git a/lib/nexus.ex b/lib/nexus.ex index 2ff5b8e..124d30a 100644 --- a/lib/nexus.ex +++ b/lib/nexus.ex @@ -1,198 +1,3 @@ defmodule Nexus do - @moduledoc """ - Nexus can be used to define simple to complex CLI applications. - - The main component of Nexus is the macro `defcommand/2`, used - to register CLI commands. Notice that the module that uses `Nexus` - is defined as a complete CLI, with own commands and logic. - - To define a command you need to name it and pass some options: - - - `:type`: the argument type to be parsed to. The absense of this option - will define a command without arguments, which can be used to define a subcommand - group. See more on the [Types](#types) section. - - `:required`: defines if the presence of the command is required or not. All commands are required by default. If you define a command as not required, you also need to define a default value. - - `:default`: defines a default value for the command. It can be any term, but it must be of the same type as the `:type` option. - - ## Usage - - defmodule MyCLI do - use Nexus - - defcommand :foo, type: :string, required?: true - - @impl true - def handle_input(:foo, _args) do - IO.puts("Hello :foo command!") - end - - Nexus.parse() - - __MODULE__.run(System.argv()) - end - - ## Types - - Nexus supports the following types: - - `:string`: parses the argument as a string. This is the default type. - - `:integer`: parses the argument as an integer. - - `:float`: parses the argument as a float. - - `:null`: parses the argument as a null value. This is useful to define subcommands. - - `{:enum, values_list}`: parses the argument as a literal, but only if it is included into the `values_list` list. Note that current it only support string or atom values. - """ - - @type command :: Nexus.Command.t() - - defmacro __using__(_opts) do - quote do - Module.register_attribute(__MODULE__, :commands, accumulate: true) - Module.register_attribute(__MODULE__, :subcommands, accumulate: true) - - import Nexus, only: [defcommand: 2, defcommand: 3] - require Nexus - - @behaviour Nexus.CLI - end - end - - @doc """ - Like `def/2`, but registers a command that can be invoked - from the command line. The `@doc` module attribute and the - arguments metadata are used to generate the CLI options. - - Each defined command produces events that can be handled using - the `Nexus.CLI` behaviour, where the event is the command - name as an atom and the second argument is a list of arguments. - """ - @spec defcommand(atom, keyword) :: Macro.t() - defmacro defcommand(cmd, opts) do - quote location: :keep do - @commands Nexus.__make_command__!(__MODULE__, unquote(cmd), unquote(opts)) - end - end - - defmacro defcommand(cmd, opts, do: ast) do - subcommands = build_subcommands(ast) - - quote location: :keep do - @commands Nexus.__make_command__!( - __MODULE__, - unquote(cmd), - Keyword.put(unquote(opts), :subcommands, unquote(subcommands)) - ) - end - end - - defp build_subcommands({:__block__, _, subs}) do - Enum.map(subs, &build_subcommands/1) - end - - defp build_subcommands({:defcommand, _, [cmd, opts]}) do - quote do - Nexus.__make_command__!(__MODULE__, unquote(cmd), unquote(opts)) - end - end - - @doc """ - Generates a default `help` command for your CLI. It uses the - optional `banner/0` callback from `Nexus.CLI` to complement - description. - - You can also define your own `help` command, copying the `quote/2` - block of this macro. - """ - defmacro help do - quote do - Nexus.defcommand(:help, type: :null, doc: "Prints this help message.") - - @impl Nexus.CLI - def handle_input(:help, _args) do - __MODULE__ - |> Nexus.help() - |> IO.puts() - end - end - end - - @doc """ - Generates three functions that can be used to manage and run - your CLI. - - ### `__commands__/0` - - Return all commands that were defined into your CLI module. - - ### `run/1` - - Run your CLI against argv content. Notice that this function only runs - a single command and returns `:ok`. It can be used to easily define - mix tasks. - - Also this function expects that the `handle_input/2` callback from `Nexus.CLI` - would have some implementation for the a comand `N` that would be parsed. - - ### `parse/1` - - Build a CLI based on argv content. It can be used if you want to manage - your CLI or decide how you want to execute functions. It builds a map - where given commands and options parsed will be keys and those values. - - #### Example - - {:ok, cli} = MyCLI.parse(System.argv) - cli.mycommand # `arg` to `mycommand` - """ - defmacro parse do - quote do - defstruct Enum.map(@commands, &{&1.name, nil}) - - def __commands__, do: @commands - - def run(args) do - raw = Enum.join(args, " ") - Nexus.CommandDispatcher.dispatch!(__MODULE__, raw) - end - - @spec parse(list(binary)) :: {:ok, Nexus.CLI.t()} | {:error, atom} - def parse(args \\ System.argv()) do - if is_list(args) do - args - |> Enum.join(" ") - |> Nexus.CLI.build(__MODULE__) - else - Nexus.CLI.build(args, __MODULE__) - end - end - end - end - - @doc """ - Given a module which defines a CLI with `Nexus`, builds - a default help string that can be printed safelly. - - This function is used when you use the `help/0` macro. - """ - def help(cli_module) do - cmds = cli_module.__commands__() - - banner = - if function_exported?(cli_module, :banner, 0) do - "#{cli_module.banner()}\n\n" - end - - """ - #{banner} - COMMANDS: - #{Enum.map_join(cmds, "\n", fn cmd -> " #{cmd.name} - #{cmd.doc}" end)} - """ - end - - def __make_command__!(module, cmd_name, opts) do - opts - |> Keyword.put(:name, cmd_name) - |> Keyword.put(:module, module) - |> Keyword.put_new(:required, false) - |> Keyword.put_new(:type, :string) - |> Nexus.Command.parse!() - end + @moduledoc false end diff --git a/lib/nexus/cli.ex b/lib/nexus/cli.ex index 6ac7d43..69b8410 100644 --- a/lib/nexus/cli.ex +++ b/lib/nexus/cli.ex @@ -1,38 +1,436 @@ defmodule Nexus.CLI do @moduledoc """ - Define callback that a CLI module needs to follow to be able - to be runned and also define helper functions to parse a single - command againts a raw input. + Nexus.CLI provides a macro-based DSL for defining command-line interfaces with commands, + flags, and positional arguments using structured ASTs with structs. """ - alias Nexus.Parser + alias Nexus.CLI.Validation, as: V + + alias Nexus.CLI.Argument + alias Nexus.CLI.Command + alias Nexus.CLI.Flag + alias Nexus.CLI.Input + + @typedoc "Represents the CLI spec, basically a list of `Command.t()` spec" + @type ast :: list(Command.t()) + + @typedoc "Represent all possible value types of an command argument or flag value" + @type value :: + :boolean + | :string + | :integer + | :float + | {:list, value} + | {:enum, list(atom | String.t())} + + @typedoc """ + Represents an final-user error while executing a command + + Need to inform the return code of the program and a reason of the error + """ + @type error :: {code :: integer, reason :: String.Chars.t()} + + @doc """ + Sets the version of the CLI + + Default implementation fetches from the `mix.exs` + """ @callback version :: String.t() + + @doc """ + Custom banners can be set + """ @callback banner :: String.t() - @callback handle_input(cmd) :: :ok - when cmd: atom - @callback handle_input(cmd, args) :: :ok - when cmd: atom, - args: Nexus.Command.Input.t() - - @optional_callbacks banner: 0, handle_input: 2, handle_input: 1 - - @type t :: map - - @spec build(list(binary), module) :: {:ok, Nexus.CLI.t()} | {:error, atom} - def build(raw, module) do - cmds = module.__commands__() - acc = {%{}, raw} - - {cli, _raw} = - Enum.reduce_while(cmds, acc, fn spec, {cli, raw} -> - try do - input = Parser.run!(raw, spec) - {:halt, {Map.put(cli, spec.name, input), raw}} - rescue - _ -> {:cont, {cli, raw}} + + @doc """ + Function that receives the current command being used and its args + + If a subcommand is being used, then the first argument will be a list + of atoms representing the command path + + Note that when returning `:ok` from this function, your program will + exit with a success code, generally `0` + + To inform errors, check the `Nexus.CLI.error()` type + + ## Examples + + @impl Nexus.CLI + def handle_input(:my_cmd, _), do: nil + + def handle_inpu([:my, :nested, :cmd], _), do: nil + """ + @callback handle_input(cmd :: atom, input :: Input.t()) :: :ok | {:error, error} + @callback handle_input(cmd :: list(atom), input :: Input.t()) :: :ok | {:error, error} + + @optional_callbacks banner: 0 + + defmodule Input do + @moduledoc "Representa a command input, with args and flags values parsed" + + @type t :: %__MODULE__{flags: %{atom => term}, args: %{atom => term}} + + defstruct [:flags, :args] + end + + defmodule Command do + @moduledoc "Represents a command or subcommand." + + alias Nexus.CLI.Argument + alias Nexus.CLI.Flag + + @type t :: %__MODULE__{ + name: atom | nil, + description: String.t() | nil, + subcommands: list(t), + flags: list(Flag.t()), + args: list(Argument.t()) + } + + defstruct name: nil, + description: nil, + subcommands: [], + flags: [], + args: [] + end + + defmodule Flag do + @moduledoc "Represents a flag (option) for a command." + + @type t :: %__MODULE__{ + name: atom | nil, + short: atom | nil, + type: Nexus.CLI.value(), + required: boolean, + default: term, + description: String.t() | nil + } + + defstruct name: nil, + short: nil, + type: :boolean, + required: false, + default: false, + description: nil + end + + defmodule Argument do + @moduledoc "Represents a positional argument for a command." + + @type t :: %__MODULE__{ + name: atom | nil, + type: Nexus.CLI.value(), + required: boolean, + default: term | nil + } + + defstruct name: nil, + type: :string, + required: false, + default: nil + end + + defmacro __using__(otp_app: app) do + quote do + import Nexus.CLI, + only: [ + defcommand: 2, + subcommand: 2, + value: 2, + flag: 2, + short: 1, + description: 1 + ] + + Module.register_attribute(__MODULE__, :cli_commands, accumulate: true) + Module.register_attribute(__MODULE__, :cli_command_stack, accumulate: false) + Module.register_attribute(__MODULE__, :cli_flag_stack, accumulate: false) + + @before_compile Nexus.CLI + + @behaviour Nexus.CLI + + @impl Nexus.CLI + def version do + vsn = + unquote(app) + |> Application.spec() + |> Keyword.get(:vsn, ~c"") + + for c <- vsn, into: "", do: <> + end + + defoverridable version: 0 + end + end + + # Macro to define a top-level command + defmacro defcommand(name, do: block) do + quote do + # Initialize a new Command struct + command = %Command{name: unquote(name)} + + # Push the command onto the command stack + Nexus.CLI.__push_command__(command, __MODULE__) + + # Execute the block to populate subcommands, flags, and args + unquote(block) + + # Finalize the command and accumulate it + Nexus.CLI.__finalize_command__(__MODULE__) + end + end + + # Macro to define a subcommand within the current command + defmacro subcommand(name, do: block) do + quote do + # Initialize a new Command struct for the subcommand + subcommand = %Command{name: unquote(name)} + + # Push the subcommand onto the command stack + Nexus.CLI.__push_command__(subcommand, __MODULE__) + + # Execute the block to populate subcommands, flags, and args + unquote(block) + + # Finalize the subcommand and attach it to its parent + Nexus.CLI.__finalize_subcommand__(__MODULE__) + end + end + + # Macro to define a positional argument + defmacro value(type, opts \\ []) do + quote do + flag_stack = Module.get_attribute(__MODULE__, :cli_flag_stack) + + if not is_nil(flag_stack) and not Enum.empty?(flag_stack) do + # we're inside a flag + Nexus.CLI.__set_flag_value__(unquote(type), unquote(opts), __MODULE__) + else + # we're inside cmd/subcmd + Nexus.CLI.__set_command_value__(unquote(type), unquote(opts), __MODULE__) + end + end + end + + # Macro to define a flag + defmacro flag(name, do: block) do + quote do + # Initialize a new Flag struct + flag = %Flag{name: unquote(name)} + + # Push the flag onto the flag stack + Nexus.CLI.__push_flag__(flag, __MODULE__) + + # Execute the block to set flag properties + unquote(block) + + # Finalize the flag and add it to its parent + Nexus.CLI.__finalize_flag__(__MODULE__) + end + end + + # Macro to define a short alias for a flag + defmacro short(short_name) do + quote do + Nexus.CLI.__set_flag_short__(unquote(short_name), __MODULE__) + end + end + + # Macro to define a description for a command, subcommand, or flag + defmacro description(desc) do + quote do + Nexus.CLI.__set_description__(unquote(desc), __MODULE__) + end + end + + # Internal functions to manage the command stack and build the AST + + # Push a command or subcommand onto the command stack + def __push_command__(command, module) do + Module.put_attribute(module, :cli_command_stack, [ + command | Module.get_attribute(module, :cli_command_stack) || [] + ]) + end + + def __finalize_command__(module) do + [command | rest] = Module.get_attribute(module, :cli_command_stack) + + command = + command + |> __process_command_arguments__() + |> V.validate_command() + + existing_commands = Module.get_attribute(module, :cli_commands) || [] + + if Enum.any?(existing_commands, &(&1.name == command.name)) do + raise Nexus.CLI.Validation.ValidationError, + "Duplicate command name: '#{command.name}'." + end + + Module.put_attribute(module, :cli_commands, command) + Module.put_attribute(module, :cli_command_stack, rest) + end + + def __finalize_subcommand__(module) do + [subcommand, parent | rest] = Module.get_attribute(module, :cli_command_stack) + + subcommand = + subcommand + |> __process_command_arguments__() + |> V.validate_command() + + # Ensure no duplicate subcommand names within the parent + if Enum.any?(parent.subcommands, &(&1.name == subcommand.name)) do + raise Nexus.CLI.Validation.ValidationError, + "Duplicate subcommand name: '#{subcommand.name}' within command '#{parent.name}'." + end + + updated_parent = Map.update!(parent, :subcommands, fn subs -> [subcommand | subs] end) + Module.put_attribute(module, :cli_command_stack, [updated_parent | rest]) + end + + # Push a flag onto the flag stack + def __push_flag__(flag, module) do + Module.put_attribute(module, :cli_flag_stack, [ + flag | Module.get_attribute(module, :cli_flag_stack) || [] + ]) + end + + def __set_flag_value__(type, opts, module) do + [flag | rest] = Module.get_attribute(module, :cli_flag_stack) + + Module.put_attribute(module, :cli_flag_stack, [ + Map.merge(flag, %{ + type: type, + required: Keyword.get(opts, :required, false), + default: Keyword.get(opts, :default) + }) + | rest + ]) + end + + def __set_command_value__(type, opts, module) do + [current | rest] = Module.get_attribute(module, :cli_command_stack) + + arg = %Argument{ + name: Keyword.get(opts, :as), + type: type, + required: Keyword.get(opts, :required, false), + default: Keyword.get(opts, :default) + } + + updated = Map.update!(current, :args, fn args -> args ++ [arg] end) + Module.put_attribute(module, :cli_command_stack, [updated | rest]) + end + + def __set_flag_short__(short_name, module) do + [flag | rest] = Module.get_attribute(module, :cli_flag_stack) + updated_flag = Map.put(flag, :short, short_name) + Module.put_attribute(module, :cli_flag_stack, [updated_flag | rest]) + end + + # Set the description for the current command, subcommand, or flag + def __set_description__(desc, module) do + flag_stack = Module.get_attribute(module, :cli_flag_stack) || [] + + if Enum.empty?(flag_stack) do + # if we're not operating on a flag, so it's a command/subcommand + stack = Module.get_attribute(module, :cli_command_stack) || [] + [current | rest] = stack + updated = Map.put(current, :description, desc) + Module.put_attribute(module, :cli_command_stack, [updated | rest]) + else + [flag | rest] = flag_stack + updated_flag = Map.put(flag, :description, desc) + Module.put_attribute(module, :cli_flag_stack, [updated_flag | rest]) + end + end + + def __finalize_flag__(module) do + [flag | rest_flag] = Module.get_attribute(module, :cli_flag_stack) + Module.put_attribute(module, :cli_flag_stack, rest_flag) + + [current | rest] = Module.get_attribute(module, :cli_command_stack) + + flag = V.validate_flag(flag) + flag = if flag.type == :boolean, do: Map.put_new(flag, :default, false), else: flag + + updated = Map.update!(current, :flags, fn flags -> [flag | flags] end) + Module.put_attribute(module, :cli_command_stack, [updated | rest]) + end + + def __process_command_arguments__(command) do + unnamed_args = Enum.filter(command.args, &(&1.name == nil)) + + cond do + Enum.empty?(unnamed_args) -> + # All arguments have names + command + + length(command.args) == 1 -> + # Single unnamed argument; set its name to the command's name + [arg] = command.args + arg = %{arg | name: command.name} + %{command | args: [arg]} + + true -> + # Multiple arguments; all must have names + raise "All arguments must have names when defining multiple arguments in command '#{command.name}'. Please specify 'as: :name' option." + end + end + + defmacro __before_compile__(env) do + commands = Module.get_attribute(env.module, :cli_commands) + + quote do + @doc false + def __nexus_cli_commands__ do + unquote(Macro.escape(commands)) + end + + @doc """ + + """ + def run(argv) when is_list(argv) or is_binary(argv) do + # Nexus.Parser will tokenize the whole input + # Mix tasks already split the argv into a list + argv = List.wrap(argv) |> Enum.join() + ast = __nexus_cli_commands__() + Nexus.CLI.__run_cli__(__MODULE__, ast, argv) + end + + @doc """ + Generates CLI documentation based into the CLI spec defined + + For more information, check `Nexus.CLI.Help` + + It receives the AST or an optional command path, for displaying + subcommands help, for example + """ + defdelegate display_help(ast, path \\ []), to: Nexus.CLI.Help, as: :display + end + end + + @spec __run_cli__(atom, ast, binary) :: term + def __run_cli__(module, ast, input) when is_list(ast) and is_binary(input) do + case Nexus.Parser.parse_ast(ast, input) do + {:ok, result} -> + input = %Input{flags: result.flags, args: result.args} + + unless function_exported?(module, :handle_input, 2) do + raise "The #{module} module doesn't implemented the handle_input/2 function" + end + + if Enum.empty?(result.command) do + module.handle_input(result.program, input) + else + module.handle_input([result.program | result.command], input) end - end) - {:ok, struct(module, cli)} + {:error, errors} = err -> + Enum.each(errors, &IO.puts/1) + err + end end end diff --git a/lib/nexus/cli/help.ex b/lib/nexus/cli/help.ex new file mode 100644 index 0000000..8af86e1 --- /dev/null +++ b/lib/nexus/cli/help.ex @@ -0,0 +1,128 @@ +defmodule Nexus.CLI.Help do + @moduledoc """ + Provides functionality to display help messages based on the CLI AST. + """ + + @doc """ + Displays help information for the given command path. + + If no command path is provided, it displays the root help. + """ + def display(ast, command_path \\ []) do + cmd = get_command(ast, command_path) + + if cmd do + # Build the full command path + full_command = build_full_command(["file" | command_path]) + + # Program description + IO.puts("#{String.capitalize(full_command)} - #{cmd.description || "No description"}\n") + + # Build usage line + usage = build_usage_line(full_command, cmd) + IO.puts("Usage: #{usage}\n") + + # Display subcommands, arguments, and options + display_subcommands(cmd) + display_arguments(cmd) + display_options(cmd) + + # Final note + if cmd.subcommands != [] do + IO.puts("Use '#{full_command} [COMMAND] --help' for more information on a command.") + end + else + IO.puts("Command not found") + end + end + + ## Helper Functions + + # Builds the full command string from the command path + defp build_full_command(command_path) do + Enum.join(command_path, " ") + end + + # Retrieves the command based on the command path + defp get_command(ast, command_path) do + root_cmd = Enum.at(ast, 0) + get_subcommand(root_cmd, command_path) + end + + defp get_subcommand(cmd, []), do: cmd + + defp get_subcommand(cmd, [name | rest]) do + subcmd = Enum.find(cmd.subcommands, fn c -> c.name == String.to_atom(name) end) + + if subcmd do + get_subcommand(subcmd, rest) + else + nil + end + end + + # Builds the usage line for the help output + defp build_usage_line(full_command, cmd) do + parts = [full_command] + + # Include options + parts = if cmd.flags != [], do: parts ++ ["[OPTIONS]"], else: parts + + # Include subcommands + parts = if cmd.subcommands != [], do: parts ++ ["[COMMAND]"], else: parts + + # Include arguments + arg_strings = + Enum.map(cmd.args, fn arg -> + if arg.required, do: "<#{arg.name}>", else: "[#{arg.name}]" + end) + + parts = parts ++ arg_strings + + Enum.join(parts, " ") + end + + # Displays subcommands if any + defp display_subcommands(cmd) do + if cmd.subcommands != [] do + IO.puts("Commands:") + + Enum.each(cmd.subcommands, fn subcmd -> + IO.puts(" #{subcmd.name} #{subcmd.description || "No description"}\n") + end) + end + end + + # Displays arguments if any + defp display_arguments(cmd) do + if cmd.args != [] do + IO.puts("Arguments:") + + Enum.each(cmd.args, &display_arg/1) + end + end + + defp display_arg(arg) do + arg_name = if arg.required, do: "<#{arg.name}>", else: "[#{arg.name}]" + IO.puts(" #{arg_name}\tType: #{format_arg_type(arg.type)}\n") + end + + # Displays options (flags), including the help option + defp display_options(cmd) do + IO.puts("Options:") + + Enum.each(cmd.flags, fn flag -> + short = if flag.short, do: "-#{flag.short}, ", else: " " + type = if flag.type != :boolean, do: " <#{String.upcase(to_string(flag.type))}>", else: "" + IO.puts(" #{short}--#{flag.name}#{type}\t#{flag.description || "No description"}") + end) + + # Include help option + IO.puts(" -h, --help\tPrint help information\n") + end + + # Formats the argument type for display + defp format_arg_type({:list, type}), do: "List of #{inspect(type)}" + defp format_arg_type({:enum, values}), do: "One of #{inspect(values)}" + defp format_arg_type(type), do: "#{inspect(type)}" +end diff --git a/lib/nexus/cli/validation.ex b/lib/nexus/cli/validation.ex new file mode 100644 index 0000000..420cd8f --- /dev/null +++ b/lib/nexus/cli/validation.ex @@ -0,0 +1,217 @@ +defmodule Nexus.CLI.Validation do + @moduledoc """ + Provides validation functions for commands, flags, and arguments within the Nexus.CLI DSL. + """ + + alias Nexus.CLI.Argument + alias Nexus.CLI.Command + alias Nexus.CLI.Flag + + @supported_types [:boolean, :string, :integer, :float] + + defmodule ValidationError do + defexception message: "Validation error" + + @spec exception(String.t()) :: %__MODULE__{message: String.t()} + def exception(msg) do + %__MODULE__{message: msg} + end + end + + @doc """ + Validates a command, including its subcommands, flags, and arguments. + """ + @spec validate_command(Command.t()) :: Command.t() + def validate_command(%Command{} = command) do + command + |> validate_command_name() + |> validate_subcommands() + |> validate_flags() + |> validate_arguments() + end + + defp validate_command_name(%Command{name: nil}) do + raise ValidationError, "Command name is required and must be an atom." + end + + defp validate_command_name(%Command{name: name} = command) when is_atom(name) do + command + end + + defp validate_command_name(%Command{name: name}) do + raise ValidationError, "Command name must be an atom, got: #{inspect(name)}." + end + + defp validate_subcommands(%Command{subcommands: subcommands} = command) do + # Validate each subcommand + subcommands = Enum.map(subcommands, &validate_command/1) + # Check for duplicate subcommand names + subcommand_names = Enum.map(subcommands, & &1.name) + duplicates = find_duplicates(subcommand_names) + + if duplicates != [] do + raise ValidationError, + "Duplicate subcommand names in command '#{command.name}': #{Enum.join(duplicates, ", ")}." + end + + %{command | subcommands: subcommands} + end + + defp validate_flags(%Command{flags: flags} = command) do + flags = Enum.map(flags, &validate_flag/1) + + flag_names = Enum.map(flags, & &1.name) + duplicates = find_duplicates(flag_names) + + if duplicates != [] do + raise ValidationError, + "Duplicate flag names in command '#{command.name}': #{Enum.join(duplicates, ", ")}." + end + + # Check for duplicate short flag names + short_names = for flag <- flags, flag.short, do: flag.short + duplicates_short = find_duplicates(short_names) + + if duplicates_short != [] do + raise ValidationError, + "Duplicate short flag aliases in command '#{command.name}': #{Enum.join(duplicates_short, ", ")}." + end + + %{command | flags: flags} + end + + defp validate_arguments(%Command{args: args} = command) do + args = Enum.map(args, &validate_argument/1) + + arg_names = Enum.map(args, & &1.name) + duplicates = find_duplicates(arg_names) + + if duplicates != [] do + raise ValidationError, + "Duplicate argument names in command '#{command.name}': #{Enum.join(duplicates, ", ")}." + end + + %{command | args: args} + end + + @doc """ + Validates a flag. + """ + @spec validate_flag(Flag.t()) :: Flag.t() + def validate_flag(%Flag{} = flag) do + flag + |> validate_flag_name() + |> validate_flag_type() + |> validate_flag_default() + end + + defp validate_flag_name(%Flag{name: nil}) do + raise ValidationError, "Flag name is required and must be an atom." + end + + defp validate_flag_name(%Flag{name: name} = flag) when is_atom(name) do + flag + end + + defp validate_flag_name(%Flag{name: name}) do + raise ValidationError, "Flag name must be an atom, got: #{inspect(name)}." + end + + defp validate_flag_type(%Flag{type: type} = flag) do + validate_type(type) + flag + end + + defp validate_flag_default(%Flag{default: default, type: type, name: name} = flag) do + if default != nil do + unless valid_default?(default, type) do + raise ValidationError, + "Default value for flag '#{name}' must be of type #{inspect(type)}, got: #{inspect(default)}." + end + end + + flag + end + + @doc """ + Validates an argument. + """ + @spec validate_argument(Argument.t()) :: Argument.t() + def validate_argument(%Argument{} = arg) do + arg + |> validate_argument_name() + |> validate_argument_type() + |> validate_argument_default() + end + + defp validate_argument_name(%Argument{name: nil}) do + raise ValidationError, "Argument name is required and must be an atom." + end + + defp validate_argument_name(%Argument{name: name} = arg) when is_atom(name) do + arg + end + + defp validate_argument_name(%Argument{name: name}) do + raise ValidationError, "Argument name must be an atom, got: #{inspect(name)}." + end + + defp validate_argument_type(%Argument{type: type} = arg) do + validate_type(type) + arg + end + + defp validate_argument_default(%Argument{default: default, type: type, name: name} = arg) do + if default != nil do + unless valid_default?(default, type) do + raise ValidationError, + "Default value for argument '#{name}' must be of type #{inspect(type)}, got: #{inspect(default)}." + end + end + + arg + end + + defp validate_type({:enum, values}) when is_list(values) do + if Enum.all?(values, &is_atom/1) or Enum.all?(values, &is_binary/1) do + :ok + else + raise ValidationError, "Enum values must be all atoms or all strings." + end + end + + defp validate_type({:list, subtype}) do + validate_type(subtype) + end + + defp validate_type(type) when type in @supported_types, do: :ok + + defp validate_type(type) do + raise ValidationError, "Unsupported type: #{inspect(type)}." + end + + defp valid_default?(default, {:enum, values}) do + Enum.member?(values, default) + end + + defp valid_default?(default, {:list, subtype}) when is_list(default) do + Enum.all?(default, fn item -> valid_default?(item, subtype) end) + end + + defp valid_default?(default, type) do + case type do + :boolean -> is_boolean(default) + :string -> is_binary(default) + :integer -> is_integer(default) + :float -> is_float(default) + _ -> false + end + end + + defp find_duplicates(list) do + list + |> Enum.frequencies() + |> Enum.filter(fn {_item, count} -> count > 1 end) + |> Enum.map(fn {item, _count} -> item end) + end +end diff --git a/lib/nexus/command.ex b/lib/nexus/command.ex deleted file mode 100644 index 0940fba..0000000 --- a/lib/nexus/command.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Nexus.Command do - @moduledoc """ - Defines a command entry for a CLI module. It also - implements some basic validations. - """ - - import Nexus.Command.Validation - - @type t :: %Nexus.Command{ - module: atom, - type: atom, - required: boolean, - name: atom, - default: term, - doc: String.t(), - subcommands: [Nexus.Command.t()] - } - - @enforce_keys ~w(module type name)a - defstruct module: nil, - required: true, - type: :string, - name: nil, - default: nil, - doc: "", - subcommands: [] - - @spec parse!(keyword) :: Nexus.Command.t() - def parse!(attrs) do - attrs - |> Map.new() - |> validate_type() - |> validate_name() - |> validate_default() - |> validate_required(:doc, &is_binary/1) - |> then(&struct(__MODULE__, &1)) - end -end diff --git a/lib/nexus/command/input.ex b/lib/nexus/command/input.ex deleted file mode 100644 index 054c4af..0000000 --- a/lib/nexus/command/input.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule Nexus.Command.Input do - @moduledoc """ - Define a structure to easy pattern matching the input - on commands dispatched - """ - - @type t :: %__MODULE__{value: term, raw: binary, subcommand: atom} - - @enforce_keys ~w(value raw)a - defstruct value: nil, raw: nil, subcommand: nil - - @spec parse!(term, binary) :: Nexus.Command.Input.t() - def parse!(value, raw) do - %__MODULE__{value: value, raw: raw} - end -end diff --git a/lib/nexus/command/missing_type.ex b/lib/nexus/command/missing_type.ex deleted file mode 100644 index ceaabd6..0000000 --- a/lib/nexus/command/missing_type.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule Nexus.Command.MissingType do - defexception message: "Command type is missing" -end diff --git a/lib/nexus/command/not_supported_type.ex b/lib/nexus/command/not_supported_type.ex deleted file mode 100644 index cb68b4e..0000000 --- a/lib/nexus/command/not_supported_type.ex +++ /dev/null @@ -1,8 +0,0 @@ -defmodule Nexus.Command.NotSupportedType do - defexception [:message] - - @impl true - def exception(type) do - %__MODULE__{message: "Command type not supported yet: #{inspect(type)}"} - end -end diff --git a/lib/nexus/command/validation.ex b/lib/nexus/command/validation.ex deleted file mode 100644 index ce1a1c0..0000000 --- a/lib/nexus/command/validation.ex +++ /dev/null @@ -1,98 +0,0 @@ -defmodule Nexus.Command.Validation do - @moduledoc """ - Defines validations for a `Nexus.Command` struct - """ - - alias Nexus.Command.MissingType - alias Nexus.Command.NotSupportedType - - @spec validate_required(map, atom, (any -> boolean)) :: map - def validate_required(%{name: name} = attrs, field, valid_type?) do - value = Map.get(attrs, field) - - if value && valid_type?.(value) do - attrs - else - raise ArgumentError, "Missing or invalid value for #{field} field in command #{name}" - end - end - - @supported_types ~w(string atom integer float null enum)a - - @spec validate_type(map) :: map - def validate_type(%{type: {:enum, values}} = attrs) do - string = Enum.all?(values, &is_same_type(&1, :string)) - atom = Enum.all?(values, &is_same_type(&1, :atom)) - - if string or atom do - attrs - else - raise ArgumentError, "Enum values must be all strings or all atoms" - end - end - - def validate_type(%{type: type} = attrs) do - if type in @supported_types do - attrs - else - raise NotSupportedType, type - end - end - - def validate_type(_), do: raise(MissingType) - - @spec validate_name(map) :: map - def validate_name(%{name: name} = attrs) do - if is_atom(name) do - attrs - else - raise ArgumentError, "Command name must be an atom" - end - end - - @spec validate_default(map) :: map - def validate_default(%{required: false, type: {:enum, values}, name: name} = attrs) do - string = Enum.all?(values, &is_same_type(&1, :string)) - atom = Enum.all?(values, &is_same_type(&1, :atom)) - type = (string && "string") || (atom && "atom") || "enum" - default = Map.get(attrs, :default) - valid_default? = is_valid_default_type?(default, string, atom) - - cond do - !default -> raise ArgumentError, "Non required commands must have a default value" - !valid_default? -> raise ArgumentError, "Default value for #{name} must be of type #{type}" - true -> attrs - end - end - - def validate_default(%{required: false, type: type, name: name} = attrs) do - default = Map.get(attrs, :default) - - cond do - !default and type != :null -> - raise ArgumentError, "Non required commands must have a default value" - - !is_same_type(default, type) -> - raise ArgumentError, "Default value for #{name} must be of type #{type}" - - true -> - attrs - end - end - - def validate_default(attrs), do: attrs - - defp is_valid_default_type?(default, string, atom) do - cond do - string -> is_binary(default) - atom -> is_atom(default) - true -> is_same_type(default, :string) - end - end - - defp is_same_type(value, :string), do: is_binary(value) - defp is_same_type(value, :integer), do: is_integer(value) - defp is_same_type(value, :float), do: is_float(value) - defp is_same_type(value, :atom), do: is_atom(value) - defp is_same_type(_, :null), do: true -end diff --git a/lib/nexus/command_dispatcher.ex b/lib/nexus/command_dispatcher.ex deleted file mode 100644 index be740d1..0000000 --- a/lib/nexus/command_dispatcher.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule Nexus.CommandDispatcher do - @moduledoc false - - alias Nexus.Command - alias Nexus.Parser - - @spec dispatch!(Nexus.command() | module, binary) :: term - - def dispatch!(%Command{} = spec, raw) do - input = Parser.run!(raw, spec) - - case {spec.type, input.value} do - {:null, nil} -> - :ok = spec.module.handle_input(spec.name) - - _ -> - :ok = spec.module.handle_input(spec.name, input) - end - end - - def dispatch!(module, raw) when is_binary(raw) do - commands = module.__commands__() - maybe_spec = Enum.reduce_while(commands, nil, &try_parse_command_name(&1, &2, raw)) - - case maybe_spec do - %Command{} = spec -> dispatch!(spec, raw) - nil -> raise "Failed to parse command #{inspect(raw)}" - end - end - - defp try_parse_command_name(spec, acc, raw) do - alias Nexus.Parser.DSL - - case DSL.literal(raw, spec.name) do - {:ok, _} -> {:halt, spec} - {:error, _} -> {:cont, acc} - end - end -end diff --git a/lib/nexus/parser.ex b/lib/nexus/parser.ex index 59073e3..84541cd 100644 --- a/lib/nexus/parser.ex +++ b/lib/nexus/parser.ex @@ -1,133 +1,330 @@ defmodule Nexus.Parser do - @moduledoc "Should parse the command and return the value" + @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. + """ - import Nexus.Parser.DSL + @type result :: %{ + program: atom, + command: list(atom), + flags: %{atom => term}, + args: %{atom => term} + } - alias Nexus.Command - alias Nexus.Command.Input - alias Nexus.FailedCommandParsing, as: Error + @doc """ + Parses the raw input string based on the given AST. + """ + @spec parse_ast(ast :: Nexus.CLI.ast(), input :: String.t()) :: + {:ok, result} | {:error, list(String.t())} + def parse_ast(ast, input) when is_list(ast) and is_binary(input) do + with {:ok, tokens} <- tokenize(input), + {:ok, program_name, tokens} <- extract_program_name(tokens), + {:ok, program_ast} <- find_program(program_name, ast), + {:ok, command_path, command_ast, tokens} <- extract_commands(tokens, program_ast), + {:ok, flags, args} <- parse_flags_and_args(tokens), + {:ok, processed_flags} <- process_flags(flags, command_ast.flags), + {:ok, processed_args} <- process_args(args, command_ast.args) do + {:ok, + %{ + program: program_name, + command: Enum.map(command_path, &String.to_existing_atom/1), + flags: processed_flags, + args: processed_args + }} + end + end - @spec run!(binary, Command.t()) :: Input.t() - def run!(raw, %Command{} = cmd) do - raw - |> String.trim_trailing() - |> String.trim_leading() - |> parse_command(cmd) + ## 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, input} -> - input + {:ok, acc, false, []} -> + {:ok, Enum.reverse(acc)} - {:error, _} -> - raise Error, "Failed to parse command #{inspect(cmd)}" + {:ok, _acc, true, _buffer} -> + {:error, "Unclosed quoted string"} - :error -> - raise Error, - "Failed to parse command #{inspect(cmd)} with subcommands #{inspect(cmd.subcommands)}" + {:error, msg} -> + {:error, [msg]} end end - defp parse_subcommand(input, cmd) do - case parse_command(input, cmd) do - {:ok, input} -> {:halt, {:ok, Map.put(input, :subcommand, cmd.name)}} - {:error, _} -> {:cont, :error} + 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 parse_command(input, %Command{type: :null, subcommands: [_ | _] = subs} = cmd) do - with {:ok, {_, rest}} <- literal(input, cmd.name) do - Enum.reduce_while(subs, :error, fn sub, _acc -> parse_subcommand(rest, sub) 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 parse_command(input, %Command{type: :null} = cmd) do - with {:ok, {_, rest}} <- literal(input, cmd.name) do - {:ok, Input.parse!(nil, rest)} + 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_program_name([program_name | rest]) do + {:ok, String.to_existing_atom(program_name), rest} + end + + defp extract_program_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 + # No matching subcommand, return current command path and ast + if command_path == [] do + {:error, "Unknown subcommand: #{token}"} + else + {:ok, command_path, current_ast, [token | rest_tokens]} + end end end - defp parse_command(input, %Command{type: :string} = cmd) do - with {:ok, {_, rest}} <- literal(input, cmd.name), - {:ok, value} <- maybe_parse_required(cmd, fn -> string(rest) end) do - {:ok, Input.parse!(value, input)} + defp extract_commands([], command_path, current_ast) do + # No more tokens + {:ok, command_path, current_ast, []} + end + + ## Lookup Functions + + defp find_program(name, ast) do + case Enum.find(ast, &(&1.name == name)) do + nil -> {:error, "Program '#{name}' not found"} + program -> {:ok, program} end end - defp parse_command(input, %Command{type: :integer} = cmd) do - with {:ok, {_, rest}} <- literal(input, cmd.name), - {:ok, value} <- maybe_parse_required(cmd, fn -> integer(rest) end) do - {:ok, - value - |> string_to!(:integer) - |> Input.parse!(input)} + ## Parsing Flags and Arguments + + defp parse_flags_and_args(tokens) do + parse_flags_and_args(tokens, [], []) + end + + defp parse_flags_and_args([], flags, args) do + {:ok, Enum.reverse(flags), Enum.reverse(args)} + end + + defp parse_flags_and_args([token | rest], flags, args) do + cond do + 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 parse_command(input, %Command{type: :float} = cmd) do - with {:ok, {_, rest}} <- literal(input, cmd.name), - {:ok, value} <- maybe_parse_required(cmd, fn -> float(rest) end) do - {:ok, - value - |> string_to!(:float) - |> Input.parse!(input)} + 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_command(input, %Command{type: :atom} = cmd) do - with {:ok, {_, rest}} <- literal(input, cmd.name), - {:ok, value} <- maybe_parse_required(cmd, fn -> string(rest) end) do - {:ok, - value - |> string_to!(:atom) - |> Input.parse!(input)} + 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 parse_command(input, %Command{type: {:enum, values}} = cmd) do - with {:ok, {_, rest}} <- literal(input, cmd.name), - {:ok, value} <- maybe_parse_required(cmd, fn -> enum(rest, values) end) do - value = - cond do - is_binary(hd(values)) -> value - is_atom(value) -> value - true -> string_to!(value, :atom) + ## Processing Flags + + defp process_flags(flag_tokens, defined_flags) do + flags = + Enum.reduce(flag_tokens, %{}, fn {_flag_type, name, value}, acc -> + name_atom = String.to_atom(name) + + flag_def = + Enum.find(defined_flags, fn flag -> + flag.name == name_atom || (flag.short && flag.short == name_atom) + end) + + if flag_def do + parsed_value = parse_value(value, flag_def.type) + + Map.put(acc, Atom.to_string(flag_def.name), parsed_value) + else + acc end + end) + + missing_required_flags = list_missing_required_flags(flags, defined_flags) - {:ok, Input.parse!(value, input)} + if Enum.empty?(missing_required_flags) do + non_parsed_flags = list_non_parsed_flags(flags, defined_flags) + + {:ok, + flags + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + |> Map.merge(non_parsed_flags)} + else + {:error, "Missing required flags: #{Enum.join(missing_required_flags, ", ")}"} end end - @spec string_to!(binary, atom) :: term - defp string_to!(raw, :integer) do - case Integer.parse(raw) do + defp list_missing_required_flags(parsed, defined) do + defined + |> Enum.filter(fn flag -> + flag.required and not Map.has_key?(parsed, Atom.to_string(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, Atom.to_string(&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 Error, "#{raw} is not a valid integer" + _ -> raise ArgumentError, "Invalid integer value: #{value}" end end - defp string_to!(raw, :float) do - case Float.parse(raw) do + 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 Error, "#{raw} is not a valid float" + _ -> raise ArgumentError, "Invalid float value: #{value}" + end + end + + defp parse_value(value, _), do: value + + ## Processing Arguments + + defp process_args(arg_tokens, defined_args) do + case process_args_recursive(arg_tokens, defined_args, %{}) do + {:ok, acc} -> {:ok, acc} + {:error, reason} -> {:error, [reason]} end end - # final user shoul not be use very often - defp string_to!(raw, :atom) do - String.to_atom(raw) + 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, value) + 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 - @spec maybe_parse_required(Command.t(), (-> {:ok, {binary, binary} | {:error, term}})) :: - {:ok, term} | {:error, term} - defp maybe_parse_required(%Command{required: true}, fun) do - with {:ok, {value, _}} <- fun.() do - {:ok, value} + 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, tokens, []} end end - defp maybe_parse_required(%Command{required: false} = cmd, fun) do - case fun.() do - {:ok, {value, _}} -> {:ok, value} - {:error, _} -> {:ok, cmd.default} + 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 diff --git a/lib/nexus/parser/dsl.ex b/lib/nexus/parser/dsl.ex deleted file mode 100644 index 8f3b769..0000000 --- a/lib/nexus/parser/dsl.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Nexus.Parser.DSL do - @moduledoc """ - Simple DSL to generate Regex "parsers" for Nexus. - """ - - def boolean(input) do - consume(input, ~r/(true|false)/) - end - - def integer(input) do - consume(input, ~r/-?\d+/) - end - - def float(input) do - consume(input, ~r/-?\d+\.\d+/) - end - - def string(input) do - consume(input, ~r/\w+/) - end - - def literal(input, lit) do - consume(input, ~r/\b#{lit}\b/) - end - - def enum(input, values) do - consume(input, ~r/\b(#{Enum.join(values, "|")}){1}\b/) - end - - defp consume(input, regex) do - if Regex.match?(regex, input) do - cap = List.first(Regex.run(regex, input, capture: :first) || []) - rest = Regex.replace(regex, input, "") - - (cap && {:ok, {cap, rest}}) || {:error, :no_match} - else - {:error, input} - end - end -end diff --git a/mix.exs b/mix.exs index 669ee12..87791c0 100644 --- a/mix.exs +++ b/mix.exs @@ -24,7 +24,8 @@ defmodule Nexus.MixProject do [extra_applications: [:logger]] end - defp elixirc_paths(:dev), do: ["lib", "examples"] + defp elixirc_paths(:dev), do: ["lib"] + defp elixirc_paths(:test), do: ["lib", "examples/file_management.ex"] defp elixirc_paths(_), do: ["lib"] defp deps do diff --git a/test/nexus/parser_test.exs b/test/nexus/parser_test.exs new file mode 100644 index 0000000..7a29273 --- /dev/null +++ b/test/nexus/parser_test.exs @@ -0,0 +1,142 @@ +defmodule Nexus.ParserTest do + use ExUnit.Case + alias Nexus.Parser + + @ast MyCLI.__nexus_cli_commands__() + + test "parses copy command with verbose flag and arguments" do + input = "file copy --verbose file1.txt file2.txt" + + expected = %{ + program: :file, + command: [:copy], + flags: %{verbose: true, level: nil, recursive: false}, + args: %{source: "file1.txt", dest: "file2.txt"} + } + + assert {:ok, parsed} = Parser.parse_ast(@ast, input) + assert parsed == expected + end + + test "parses move command with force flag and arguments" do + input = "file move --force source.txt dest.txt" + + expected = %{ + program: :file, + command: [:move], + flags: %{force: true, verbose: false}, + args: %{source: "source.txt", dest: "dest.txt"} + } + + assert {:ok, parsed} = Parser.parse_ast(@ast, input) + assert parsed == expected + end + + test "parses delete command with multiple flags and arguments" do + input = "file delete --force --recursive file1.txt file2.txt" + + expected = %{ + program: :file, + command: [:delete], + flags: %{force: true, recursive: true, verbose: false}, + args: %{targets: ["file1.txt", "file2.txt"]} + } + + assert {:ok, parsed} = Parser.parse_ast(@ast, input) + assert parsed == expected + end + + test "fails on unknown subcommand" do + input = "file unknown_command" + assert {:error, "Unknown subcommand: unknown_command"} = Parser.parse_ast(@ast, input) + end + + test "fails on missing required arguments" do + input = "file copy --verbose file1.txt" + assert {:error, ["Missing required argument 'dest'"]} = Parser.parse_ast(@ast, input) + end + + test "parses copy command with short flag and arguments" do + input = "file copy -v file1.txt file2.txt" + + expected = %{ + program: :file, + command: [:copy], + flags: %{verbose: true, level: nil, recursive: false}, + args: %{source: "file1.txt", dest: "file2.txt"} + } + + assert {:ok, parsed} = Parser.parse_ast(@ast, input) + assert parsed == expected + end + + test "parses move command with verbose flag using short alias" do + input = "file move -v source.txt dest.txt" + + expected = %{ + program: :file, + command: [:move], + flags: %{verbose: true, force: false}, + args: %{source: "source.txt", dest: "dest.txt"} + } + + assert {:ok, parsed} = Parser.parse_ast(@ast, input) + assert parsed == expected + end + + test "parses delete command with flags in different order" do + input = "file delete --recursive --force file1.txt file2.txt" + + expected = %{ + program: :file, + command: [:delete], + flags: %{recursive: true, force: true, verbose: false}, + args: %{targets: ["file1.txt", "file2.txt"]} + } + + assert {:ok, parsed} = Parser.parse_ast(@ast, input) + assert parsed == expected + end + + test "parses copy command with flag value" do + input = "file copy --level=3 file1.txt file2.txt" + + expected = %{ + program: :file, + command: [:copy], + flags: %{level: 3, recursive: false, verbose: false}, + args: %{source: "file1.txt", dest: "file2.txt"} + } + + assert {:ok, parsed} = Parser.parse_ast(@ast, input) + assert parsed == expected + end + + test "parses copy command with negative flag value" do + input = "file copy --level=-2 file1.txt file2.txt" + + expected = %{ + program: :file, + command: [:copy], + flags: %{level: -2, verbose: false, recursive: false}, + args: %{source: "file1.txt", dest: "file2.txt"} + } + + assert {:ok, parsed} = Parser.parse_ast(@ast, input) + assert parsed == expected + end + + test "parses copy command with quoted string argument" do + input = "file copy --verbose \"file 1.txt\" \"file 2.txt\"" + + expected = %{ + program: :file, + command: [:copy], + flags: %{verbose: true, level: nil, recursive: false}, + args: %{source: "file 1.txt", dest: "file 2.txt"} + } + + assert {:ok, parsed} = Parser.parse_ast(@ast, input) + assert parsed == expected + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index ddaddff..869559e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,17 +1 @@ ExUnit.start() - -defmodule MyCLITest do - use Nexus - - defcommand :test, type: :string, default: "hello", doc: "Test Command" - - @impl true - def version, do: "0.0.0" - - @impl true - def handle_input(:test, args) do - args - end - - Nexus.parse() -end