Skip to content

Commit

Permalink
Improve command parsing (#13)
Browse files Browse the repository at this point in the history
* feat: better command literal and values parsing

* feat: add default prop validation and tests

* feat: match command dispatchment with new requirements

* fix: credo

* i didn't know what happened

* modify version

* feat: add readme links
  • Loading branch information
zoedsoupe authored Sep 10, 2023
1 parent 0125efa commit edbcbb2
Show file tree
Hide file tree
Showing 14 changed files with 177 additions and 41 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,10 @@ nexus-*.tar

# Temporary files, for example, from tests.
/tmp/

# Nix files
result
/.nix-mix/

# Escript builds
nexus
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Nexus

⚠️ This library is highly experimental and not ready for production use! Expect breaking changes! ⚠️

**YOU HAVE BEEN WARNED!**

```sh
_ __
|\ ||_\/| |(_
Expand All @@ -10,10 +14,11 @@
An `Elixir` library to write command line apps in a cleaner and elegant way!

[![lint](https://github.com/zoedsoupe/nexus/actions/workflows/lint.yml/badge.svg)](https://github.com/zoedsoupe/nexus/actions/workflows/lint.yml)
[![test](https://github.com/zoedsoupe/nexus/actions/workflows/test.yml/badge.svg)](https://github.com/zoedsoupe/nexus/actions/workflows/test.yml)
[![Hex.pm](https://img.shields.io/hexpm/v/nexus_cli.svg)](https://hex.pm/packages/nexus_cli)
[![Downloads](https://img.shields.io/hexpm/dt/nexus_cli.svg)](https://hex.pm/packages/nexus_cli)
[![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/nexus_cli)
[![lint](https://github.com/zoedsoupe/nexus/actions/workflows/lint.yml/badge.svg)](https://github.com/zoedsoupe/nexus/actions/workflows/lint.yml)
[![test](https://github.com/zoedsoupe/nexus/actions/workflows/test.yml/badge.svg)](https://github.com/zoedsoupe/nexus/actions/workflows/test.yml)

## Example

Expand Down
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import Config

config :nexus, supported_types: ~w(string atom integer float)a
config :nexus, supported_types: ~w(string atom integer float null)a
3 changes: 2 additions & 1 deletion examples/escript/example.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ defmodule Escript.Example do

use Nexus

defcommand :foo, required?: true, type: :string
defcommand :foo, required: true, type: :string

@impl true
def version, do: "0.1.0"
Expand All @@ -25,6 +25,7 @@ defmodule Escript.Example do
IO.puts(inspect(input))
end

Nexus.help()
Nexus.parse()

defdelegate main(args), to: __MODULE__, as: :run
Expand Down
2 changes: 1 addition & 1 deletion examples/mix/tasks/example.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ defmodule Mix.Tasks.Example do
use Mix.Task
use Nexus

defcommand :foo, type: :string, required?: false
defcommand :foo, type: :string, required: false, default: "bar"

@impl Nexus.CLI
def version, do: "0.1.0"
Expand Down
8 changes: 5 additions & 3 deletions lib/nexus.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ defmodule Nexus do
To define a command you need to name it and pass some options:
- `:type`: the argument type to be parsed to. It can be `:string` (default),
`:integer`, `:float`, `{:enum, list(option)}`. The absense of this option
`:integer`, `:float` or `:atom`. The absense of this option
will define a command without arguments, which can be used to define a subcommand
group.
- `:required?`: defines if the presence of the command is required or not. All commands are required by default.
- `: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
Expand Down Expand Up @@ -117,7 +118,8 @@ defmodule Nexus do
def __commands__, do: @commands

def run(args) do
Nexus.CommandDispatcher.dispatch!(__MODULE__, args)
raw = Enum.join(args, " ")
Nexus.CommandDispatcher.dispatch!(__MODULE__, raw)
end

@spec parse(list(binary)) :: {:ok, Nexus.CLI.t()} | {:error, atom}
Expand Down
4 changes: 1 addition & 3 deletions lib/nexus/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ defmodule Nexus.CLI do
to be runned and also define helper functions to parse a single
command againts a raw input.
"""
alias Nexus.Command.Input
alias Nexus.Parser

@callback version :: String.t()
Expand All @@ -24,8 +23,7 @@ defmodule Nexus.CLI do

{cli, _raw} =
Enum.reduce(cmds, acc, fn spec, {cli, raw} ->
{value, raw} = Parser.command_from_raw!(spec, raw)
input = Input.parse!(value, raw)
input = Parser.run!(raw, spec)
{Map.put(cli, spec.name, input), raw}
end)

Expand Down
11 changes: 9 additions & 2 deletions lib/nexus/command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,24 @@ defmodule Nexus.Command do

import Nexus.Command.Validation

@type t :: %Nexus.Command{module: atom, type: String.t(), required?: boolean, name: atom}
@type t :: %Nexus.Command{
module: atom,
type: atom,
required: boolean,
name: atom,
default: term
}

@enforce_keys ~w(module type name)a
defstruct module: nil, required?: true, type: nil, name: nil
defstruct module: nil, required: true, type: nil, name: nil, default: nil

@spec parse!(keyword) :: Nexus.Command.t()
def parse!(attrs) do
attrs
|> Map.new()
|> validate_type()
|> validate_name()
|> validate_default()
|> then(&struct(__MODULE__, &1))
end
end
4 changes: 2 additions & 2 deletions lib/nexus/command/input.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ defmodule Nexus.Command.Input do
on commands dispatched
"""

@type t :: %__MODULE__{value: term, raw: list(binary())}
@type t :: %__MODULE__{value: term, raw: binary}

@enforce_keys ~w(value raw)a
defstruct value: nil, raw: nil

@spec parse!(term, list(binary())) :: Nexus.Command.Input.t()
@spec parse!(term, binary) :: Nexus.Command.Input.t()
def parse!(value, raw) do
%__MODULE__{value: value, raw: raw}
end
Expand Down
24 changes: 24 additions & 0 deletions lib/nexus/command/validation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,28 @@ defmodule Nexus.Command.Validation do
raise ArgumentError, "Command name must be an atom"
end
end

@spec validate_name(map) :: map
def validate_default(%{required: false, type: type, name: name} = attrs) do
default = Map.get(attrs, :default)

cond do
!default ->
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_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
30 changes: 15 additions & 15 deletions lib/nexus/command_dispatcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,31 @@ defmodule Nexus.CommandDispatcher do
@moduledoc false

alias Nexus.Command
alias Nexus.Command.Input
alias Nexus.Parser

@spec dispatch!(Nexus.command() | binary | list(binary), list(binary)) :: term
@spec dispatch!(Nexus.command() | module, binary) :: term

def dispatch!(%Command{} = spec, raw) do
{value, raw} = Parser.command_from_raw!(spec, raw)
input = Input.parse!(value, raw)
input = Parser.run!(raw, spec)
spec.module.handle_input(spec.name, input)
end

def dispatch!(module, args) when is_binary(args) do
dispatch!(module, String.split(args, ~r/\s/))
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

def dispatch!(module, args) when is_list(args) do
cmd =
Enum.find(module.__commands__(), fn %{name: n} ->
to_string(n) == List.first(args)
end)
defp try_parse_command_name(spec, acc, raw) do
alias Nexus.Parser.DSL

if cmd do
dispatch!(cmd, args)
else
raise "Command #{hd(args)} not found in #{module}"
case DSL.literal(raw, spec.name) do
{:ok, _} -> {:halt, spec}
{:error, _} -> {:cont, acc}
end
end
end
77 changes: 67 additions & 10 deletions lib/nexus/parser.ex
Original file line number Diff line number Diff line change
@@ -1,25 +1,68 @@
defmodule Nexus.Parser do
@moduledoc "Should parse the command and return the value"

import Nexus.Parser.DSL

alias Nexus.Command
alias Nexus.Command.Input
alias Nexus.FailedCommandParsing, as: Error

@spec command_from_raw!(Command.t(), binary | list(binary)) :: {term, list(binary)}
def command_from_raw!(cmd, raw) when is_binary(raw) do
command_from_raw!(cmd, String.split(raw, ~r/\s/))
@spec run!(binary, Command.t()) :: Input.t()
def run!(raw, %Command{} = cmd) do
raw
|> String.trim_trailing()
|> String.trim_leading()
|> parse_command(cmd)
|> case do
{:ok, input} -> input
{:error, _} -> raise Error, "Failed to parse command #{inspect(cmd)}"
end
end

defp parse_command(input, %Command{type: :null} = cmd) do
with {:ok, {_, rest}} <- literal(input, cmd.name) do
{:ok, Input.parse!(nil, rest)}
end
end

def command_from_raw!(%Command{name: name, type: t}, args) when is_list(args) do
ns = to_string(name)
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)}
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)}
end
end

case args do
[^ns, value | args] -> {string_to!(value, t), args}
args -> raise "Failed to parse command #{ns} with args #{inspect(args)}"
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)}
end
end

defp string_to!(raw, :string), do: raw
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)}
end
end

@spec string_to!(binary, atom) :: term
defp string_to!(raw, :integer) do
case Integer.parse(raw) do
{int, ""} -> int
Expand All @@ -34,8 +77,22 @@ defmodule Nexus.Parser do
end
end

# final user should not be used very often
# final user shoul not be use very often
defp string_to!(raw, :atom) do
String.to_atom(raw)
end

@spec maybe_parse_required(Command.t(), function) :: {:ok, term} | {:error, term}
defp maybe_parse_required(%Command{required: true}, fun) do
with {:ok, {value, _}} <- fun.() do
{:ok, value}
end
end

defp maybe_parse_required(%Command{required: false} = cmd, fun) do
case fun.() do
{:ok, {value, _}} -> {:ok, value}
{:error, _} -> {:ok, cmd.default}
end
end
end
35 changes: 35 additions & 0 deletions lib/nexus/parser/dsl.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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/[a-zA-Z]+/)
end

def literal(input, lit) do
consume(input, ~r/\b#{lit}\b/)
end

defp consume(input, regex) do
if Regex.match?(regex, input) do
cap = hd(Regex.run(regex, input, capture: :first))
rest = Regex.replace(regex, input, "")
{:ok, {cap, rest}}
else
{:error, input}
end
end
end
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Nexus.MixProject do
use Mix.Project

@version "0.2.0"
@version "0.3.0"
@source_url "https://github.com/zoedsoupe/nexus"

def project do
Expand Down

0 comments on commit edbcbb2

Please sign in to comment.